dsh-inner-browser-use 0.34.28 → 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/README.md CHANGED
@@ -10,17 +10,22 @@ live picture of that browser, and your own cursor keeps working on the picture.
10
10
 
11
11
  | Tool | What it does |
12
12
  | --- | --- |
13
- | `sidecar_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
14
- | `sidecar_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
15
- | `sidecar_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
16
- | `sidecar_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
17
- | `sidecar_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
18
- | `sidecar_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down, Home, End, PageUp, PageDown. |
19
- | `sidecar_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
20
- | `sidecar_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
21
- | `sidecar_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
22
-
23
- `sidecar_view` answers with one page of text:
13
+ | `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
14
+ | `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
15
+ | `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the passages that answer it come back, ranked (BM25 over Readability-style blocks, link-heavy menus down-weighted). |
16
+ | `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
17
+ | `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
18
+ | `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
19
+ | `browser_type` | Types text into the focused field, or clicks a numbered control first. Chinese goes in as one string. `submit: true` presses Enter. |
20
+ | `browser_key` | Presses one key: Enter, Tab, Escape, Backspace, Delete, Insert, arrows, Home, End, PageUp, PageDown, F1-F12, the numeric keypad, letters, digits, punctuation and every shifted symbol, plus ContextMenu, CapsLock, PrintScreen and media keys. `Ctrl`/`Alt`/`Shift`/`Meta`/`Mod` modifiers, chords with `+`, and `Ctrl+C/X/V/A/Z/Y` run the real editing commands. |
21
+ | `browser_scroll` | Scrolls the wheel at the centre of the page, or at `x`/`y`. `dy: 600` is about a screen down. |
22
+ | `browser_shot` | Saves a full-size PNG into `~/.dsh-inner-browser-use/screenshots` and returns the path. |
23
+ | `browser_state` | Reports whether the browser runs, which profile it uses, its address and title, and how many columns are showing it. |
24
+
25
+ `browser_read` on the same page is the words only, in Markdown (measured: 4,520
26
+ characters of an about page against 2,987 characters of snapshot — and the
27
+ snapshot spends most of its budget on controls; on a long article the gap grows
28
+ with the number of controls skipped). `browser_view` answers with one page of text:
24
29
 
25
30
  ```
26
31
  https://example.com
@@ -34,8 +39,9 @@ Numbers stay valid until the next read. Anything that changes the page returns a
34
39
 
35
40
  ## The column
36
41
 
37
- The pane is a picture of the browser, sent as JPEG over the session's own stream: 1440×900,
38
- quality 72, every frame, about 82 KB and 25 per second on a page at rest.
42
+ The pane is a picture of the browser, sent as JPEG over the session's own stream: quality 72,
43
+ every frame, about 82 KB and 25 per second on a page at rest. The page is laid out at the size
44
+ of the column, so the picture fills the frame instead of sitting letterboxed inside it.
39
45
 
40
46
  Two pointers live on that picture:
41
47
 
@@ -82,7 +88,9 @@ Screenshots are the only thing written outside the profile:
82
88
  | Read, dense page | 444 tokens (1510 chars, 90 controls) |
83
89
  | Read round trip | 1–4 ms |
84
90
  | Click round trip | 2 ms |
85
- | Screenshot | 77 ms, 113 KB |
91
+ | Key round trip | 7 ms |
92
+ | Key to next frame | 15–18 ms |
93
+ | Screenshot | 75 ms, 115 KB |
86
94
 
87
95
  ## Install
88
96
 
@@ -102,6 +110,22 @@ dsh plugin install ./dsh-inner-browser-use-0.16.0.tgz
102
110
  The plugin appears under **Plugins** as *Background browser*. It needs Google Chrome or
103
111
  Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
104
112
 
113
+ ## When it opens
114
+
115
+ Only when the person asks for it by name: "in the browser", "on the web page",
116
+ "open this site", "click it for me". A link on its own is not a request.
117
+
118
+ - A public article, one link's text, docs, a GitHub repo or PR: read it with
119
+ `web_fetch` or Envoy. No browser, no Chrome process.
120
+ - If the page needs a login, is an empty shell to `web_fetch`, or needs a click,
121
+ say why and ask once instead of starting a browser on your own.
122
+ - Asked for, it starts without a question and never opens a second, external
123
+ Chrome. The picture stays in this chat's right column and stays open when the
124
+ work is done.
125
+
126
+ One browser is shared by every chat (see [Sharing](#sharing)), so opening one is
127
+ a tab, not a second Chrome.
128
+
105
129
  ## Use
106
130
 
107
131
  1. Open the chat you want the agent to browse for.
@@ -110,24 +134,35 @@ Chromium on the machine; see [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md).
110
134
  The first call starts the browser and puts its mirror in this chat's right column. Nothing
111
135
  opens before a call: a chat that never asked for a page keeps its column as it was.
112
136
 
137
+ ## Sharing
138
+
139
+ Every chat uses the same background Chrome: one process, one tab per chat, and
140
+ each tab's address saved so it can be closed and reopened cheaply. Past six
141
+ chats the oldest tab nobody is watching is closed (a watched tab — the one on
142
+ someone's screen — never is). Tabs an earlier plugin load left behind are closed
143
+ on first use: one profile used for weeks otherwise keeps a renderer per chat that
144
+ ever opened it. Nothing is launched per chat, and the browser itself is closed 15
145
+ minutes after the last mirror is detached.
146
+
113
147
  ## Frames, downloads, network
114
148
 
115
149
  - **Frames are read, including cross-site ones.** Controls inside an iframe are numbered like
116
150
  the page's own, whether Chrome shares the page's process or runs the frame apart; a click in
117
151
  either lands where the frame actually sits.
118
152
  - **A download lands on disk.** A click that saves a file writes it into the browser's own
119
- profile folder, and `sidecar_downloads` names it, its size and when it arrived.
120
- - **The network is visible.** `sidecar_network` lists what the page asked for, newest first,
153
+ profile folder, and `browser_downloads` names it, its size and when it arrived.
154
+ - **The network is visible.** `browser_network` lists what the page asked for, newest first,
121
155
  so a page that fills itself in from an address can be understood without a screenshot.
122
156
  - **An overlay does not eat the action.** A cookie banner over the button is cleared the way a
123
157
  person would clear it, then the click lands — in the same call, and in every step of a
124
- `sidecar_do` batch. Anything offering to delete, pay or sign out is reported instead.
158
+ `browser_do` batch. Anything offering to delete, pay or sign out is reported instead.
125
159
 
126
160
  ## What it does not do
127
161
 
128
162
  - **It does not touch your Chrome.** The browser runs from a separate profile with no
129
163
  connection to the window you are using.
130
- - **The viewport is fixed.** 1440×900, desktop layout, no resizing and no mobile emulation.
164
+ - **The layout viewport is the column, nothing more.** Desktop layout, no mobile emulation, no
165
+ touch emulation; a very narrow column is laid out narrow rather than zoomed out.
131
166
  - **One page at a time.** A link that opens a tab is followed — the mirror lands on the new
132
167
  page and the popup closes — so there is never a second page to choose between.
133
168
 
package/README.zh.md CHANGED
@@ -9,17 +9,21 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
9
9
 
10
10
  | 工具 | 作用 |
11
11
  | --- | --- |
12
- | `sidecar_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
13
- | `sidecar_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
14
- | `sidecar_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
15
- | `sidecar_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
16
- | `sidecar_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
17
- | `sidecar_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
18
- | `sidecar_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
19
- | `sidecar_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
20
- | `sidecar_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
21
-
22
- `sidecar_view` 一次给一页文本:
12
+ | `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
13
+ | `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
14
+ | `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的段落;问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
15
+ | `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
16
+ | `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
17
+ | `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
18
+ | `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
19
+ | `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
20
+ | `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
21
+ | `browser_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
22
+ | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
23
+
24
+ 点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
25
+
26
+ `browser_view` 一次给一页文本:
23
27
 
24
28
  ```
25
29
  https://example.com
@@ -72,6 +76,7 @@ profile 之外只写一样东西:`~/.dsh-inner-browser-use/screenshots/` 里
72
76
  | 单帧 | 1440×900 JPEG,约 82 KB |
73
77
  | 画面 | 约 25 帧每秒 |
74
78
  | 指针移动到箭头跟上 | 0.8–7 ms |
79
+ | 右栏里手动点击到画面更新 | 中位 13 ms,最坏 17 ms(点击派发 2 ms、悬停 8 ms) |
75
80
 
76
81
  ## 安装
77
82
 
@@ -98,12 +103,19 @@ dsh plugin install ./dsh-inner-browser-use-0.12.1.tgz
98
103
  第一次调用会启动浏览器,并把镜像放进这个会话的右栏。没有调用就不会有东西自己打开:没要过
99
104
  页面的会话,右栏保持原样。
100
105
 
106
+ ## Flash 页面
107
+
108
+ `browser_open` 带 `flash: true` 时,这个地址改用插件自带的 Flash 运行时(Electron 87 + Pepper Flash)在**离屏窗口**里播放:窗口一次都不显示,Dock 与菜单栏都不出现,画面只在发起会话的右栏里。切换回来再调一次 `flash: false`,选择会记住。运行时首次使用时下载到 `~/.dsh/inner-browser-use/flash/`,不需要单独安装播放器。
109
+
110
+ `npm run flash-hidden` 会真起一次运行时并断言「窗口未显示 / 帧持续产出 / 两帧不同」;`npm run flash-mirror` 量镜像帧率与按下/抬起/按键耗时;`npm run flash-tools` 量工具层成本(奥拉星登录页实测:点击 28 ms、输入 14 ms、`browser_view` 31 ms;同一页后续回话只说标题,点击 22 字符、输入 13 字符);`npm run flash-local` 用仓库自带的 SWF 在本机起页,不依赖网络,量首帧与输入直达。
111
+
101
112
  ## 框架、下载、网络
102
113
 
103
114
  - **iframe 能读,跨站也能。** 不管是和页面共用进程,还是 Chrome 单独给一个进程,里面的控件都和页面自己的控件一样有编号;点击按框架在页面上的实际位置落点。
104
- - **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`sidecar_downloads` 报出文件名、大小和到达时间。
105
- - **能看到网络。** `sidecar_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
106
- - **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`sidecar_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
115
+ - **下载会落到磁盘。** 点一下就下载的文件写在这个浏览器自己的 profile 目录里,`browser_downloads` 报出文件名、大小和到达时间。
116
+ - **PDF 有去处。** Chrome 无头进程画不出 PDF,右栏只会是白图;读到 PDF 时把文件取回归档到该浏览器 profile 的 `downloads/`,结果里直接给绝对路径,交给 `envoy_read` 或本地阅读器。
117
+ - **能看到网络。** `browser_network` 倒序列出页面请求了什么,自己用接口填内容、屏幕上又看不出原因的页面,不用截图去猜。
118
+ - **遮罩不吞动作。** 盖在按钮上的 cookie 横幅会按人的做法先关掉再点下去,同一次调用里完成;`browser_do` 的每一步都一样。写着删除、支付、退出的遮罩只报告,不代按。
107
119
 
108
120
  ## 不做什么
109
121
 
package/client.js CHANGED
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
32
32
  const DICT = {
33
33
  zh: {
34
34
  'side.label': '后台浏览器',
35
- 'side.empty': '还没有打开的页面,在上方地址栏输入网址',
35
+ 'side.empty': '后台的独立 Chrome 还没有页面,在上方地址栏输入网址',
36
36
  'side.restore': '恢复上次的页面',
37
37
  'side.connecting': '正在连接…',
38
38
  'side.failed': '画面没有连上',
@@ -45,7 +45,7 @@ window.__ModuleLoader__.load({
45
45
  },
46
46
  en: {
47
47
  'side.label': 'Background browser',
48
- 'side.empty': 'No page open; type an address above',
48
+ 'side.empty': 'The background Chrome has no page yet; type an address above',
49
49
  'side.restore': 'Reopen the last page',
50
50
  'side.connecting': 'Connecting…',
51
51
  'side.failed': 'The picture did not connect',
@@ -167,6 +167,12 @@ window.__ModuleLoader__.load({
167
167
  single: true,
168
168
  priority: 'extension',
169
169
  title: () => L('side.label'),
170
+ /* The tab strip's close control is not the pane's own button: a
171
+ tab closed there never told the host, the ask stayed alive, and
172
+ the keepalive put the mirror back a few seconds later — a tab
173
+ the person closed that would not stay closed. Every close path
174
+ ends here. */
175
+ onClose: (_tab, where) => closeHere(String(where?.sessionId ?? mountedOf())),
170
176
  })))
171
177
  disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
172
178
  name: SLOT, // ui-copy-ignore
@@ -227,7 +233,10 @@ window.__ModuleLoader__.load({
227
233
  const watchAsk = () => {
228
234
  try {
229
235
  if (typeof surface?.mounted?.subscribe === 'function') {
230
- const off = surface.mounted.subscribe(syncAsk)
236
+ const off = surface.mounted.subscribe(() => {
237
+ syncAsk()
238
+ restore()
239
+ })
231
240
  if (typeof off === 'function') return off
232
241
  }
233
242
  } catch {
@@ -236,10 +245,41 @@ window.__ModuleLoader__.load({
236
245
  return null
237
246
  }
238
247
  syncAsk()
248
+ /* An app restart leaves the browser alive but no ask on the wire, so
249
+ the column would come back empty in a chat that never closed it.
250
+ This chat asking for the state of the browser it still owns is what
251
+ puts the mirror back without anyone pressing anything. */
252
+ const restoreTimers = []
253
+ const restore = (tries = 0) => {
254
+ const who = mountedOf()
255
+ if (who === '') return
256
+ const again = () => {
257
+ if (tries >= 2) return
258
+ restoreTimers.push(setTimeout(() => restore(tries + 1), 1500))
259
+ }
260
+ try {
261
+ fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`) // ui-copy-ignore
262
+ .then((said) => (said.ok === true ? said.json() : null))
263
+ .then((state) => {
264
+ if (state?.owner !== who) return
265
+ const live = state?.alive === true || state?.chat?.alive === true
266
+ if (live !== true) {
267
+ again()
268
+ return
269
+ }
270
+ ask(who)
271
+ })
272
+ .catch(again)
273
+ } catch {
274
+ again()
275
+ }
276
+ }
277
+ restore()
239
278
  const offAsk = watchAsk()
240
279
  const pollAsk = setInterval(syncAsk, 1000)
241
280
  const stopAsk = () => {
242
281
  clearInterval(pollAsk)
282
+ for (const timer of restoreTimers) clearTimeout(timer)
243
283
  if (typeof offAsk === 'function') {
244
284
  try {
245
285
  offAsk()
@@ -274,6 +314,32 @@ window.__ModuleLoader__.load({
274
314
  function SidecarBody(props) {
275
315
  const [frame, setFrame] = React.useState(null)
276
316
  const [ptr, setPtr] = React.useState(null)
317
+ /* The page is laid out at the size of this column, so the picture it
318
+ sends already has the right shape; these are its pixels. */
319
+ const [view, setView] = React.useState({ w: SIDE_VIEW.width, h: SIDE_VIEW.height })
320
+ /* The person at the mirror drives with their own hand: while they are,
321
+ the agent's arrow stays out of the way, and it comes back by itself
322
+ once their hold runs out. */
323
+ const [person, setPerson] = React.useState(false)
324
+ const personTimer = React.useRef(0)
325
+ /* The person's own hand hides the arrow the moment it lands, without
326
+ waiting for the browser to report a hold: the arrow is a claim about
327
+ what the agent did, and a claim that outlives the agent's turn is a lie
328
+ the person has to look past. It returns when their hand stops. */
329
+ const [hand, setHand] = React.useState(false)
330
+ const handTimer = React.useRef(0)
331
+ const touched = () => {
332
+ setHand(true)
333
+ clearTimeout(handTimer.current)
334
+ handTimer.current = setTimeout(() => setHand(false), 1500)
335
+ }
336
+ const hearHold = (message) => {
337
+ const hold = Number(message?.hold ?? 0)
338
+ if (!Number.isFinite(hold) || hold <= 0) return
339
+ setPerson(true)
340
+ clearTimeout(personTimer.current)
341
+ personTimer.current = setTimeout(() => setPerson(false), hold + 120)
342
+ }
277
343
  const [phase, setPhase] = React.useState('connecting')
278
344
  const [note, setNote] = React.useState('')
279
345
  /* The address this chat had before its tab went away: the pane reopens
@@ -384,11 +450,15 @@ window.__ModuleLoader__.load({
384
450
  pending = null
385
451
  setFrame(message)
386
452
  setPtr(message.pointer ?? null)
453
+ if (Number.isFinite(Number(message.w)) && Number.isFinite(Number(message.h))) {
454
+ setView({ w: Number(message.w), h: Number(message.h) })
455
+ }
387
456
  setPhase('live')
388
457
  drawnAt.current = Date.now()
389
458
  if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
390
459
  }
391
460
  const draw = (message) => {
461
+ hearHold(message)
392
462
  pending = message
393
463
  if (raf !== 0) return
394
464
  if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
@@ -417,6 +487,7 @@ window.__ModuleLoader__.load({
417
487
  } else if (message.type === 'pointer') {
418
488
  /* The arrow is drawn the moment the agent moves, not when the page
419
489
  next repaints: it stays honest even on a page that is idle. */
490
+ hearHold(message)
420
491
  setPtr(message.pointer ?? null)
421
492
  } else if (typeof message.jpeg === 'string') {
422
493
  draw(message)
@@ -425,6 +496,8 @@ window.__ModuleLoader__.load({
425
496
  source.onerror = () => setPhase('lost')
426
497
  return () => {
427
498
  clearInterval(stuck)
499
+ clearTimeout(personTimer.current)
500
+ clearTimeout(handTimer.current)
428
501
  if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
429
502
  source.close()
430
503
  }
@@ -448,9 +521,49 @@ window.__ModuleLoader__.load({
448
521
  return () => observer.disconnect()
449
522
  }, [])
450
523
 
451
- const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / SIDE_VIEW.width, size.h / SIDE_VIEW.height) : 0
452
- const padX = (size.w - SIDE_VIEW.width * scale) / 2
453
- const padY = (size.h - SIDE_VIEW.height * scale) / 2
524
+ const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
525
+ const padX = (size.w - view.w * scale) / 2
526
+ const padY = (size.h - view.h * scale) / 2
527
+
528
+ /* The column tells the page how big it is, a beat after the resize stops:
529
+ a page that relayouts on every pointer step would never settle. */
530
+ React.useEffect(() => {
531
+ if (mine !== true || size.w < 200 || size.h < 200) return undefined
532
+ /* The page already has that shape: a resize that lands between two
533
+ viewport sizes would only cost a relayout and another frame. */
534
+ if (Math.abs(Number(view?.w ?? 0) - size.w) < 1 && Math.abs(Number(view?.h ?? 0) - size.h) < 1) return undefined
535
+ /* The page is told the column's size a beat after the resize stops, and
536
+ again until the browser says it took it: a request that arrives while
537
+ the page is still starting is refused, and a pane that gives up then
538
+ keeps a letterboxed picture for as long as it stays open. */
539
+ let live = true
540
+ let tries = 0
541
+ let timer = 0
542
+ const send = () => {
543
+ if (!live) return
544
+ fetch(`${ROUTE}/sidecar/viewport`, {
545
+ method: 'POST',
546
+ headers: { 'content-type': 'application/json' },
547
+ body: JSON.stringify({ session: String(props.sessionId ?? ''), w: Math.round(size.w), h: Math.round(size.h) }),
548
+ })
549
+ .then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
550
+ .then((said) => {
551
+ if (!live || said?.ok === true || tries >= 5) return
552
+ tries += 1
553
+ timer = setTimeout(send, 400)
554
+ })
555
+ .catch(() => {
556
+ if (!live || tries >= 5) return
557
+ tries += 1
558
+ timer = setTimeout(send, 600)
559
+ })
560
+ }
561
+ timer = setTimeout(send, 250)
562
+ return () => {
563
+ live = false
564
+ clearTimeout(timer)
565
+ }
566
+ }, [mine, size.w, size.h, view?.w, view?.h])
454
567
 
455
568
  const who = String(props.sessionId ?? '')
456
569
  const post = (path, body) => {
@@ -487,7 +600,7 @@ window.__ModuleLoader__.load({
487
600
  const box = el.getBoundingClientRect()
488
601
  const x = (event.clientX - box.left - padX) / scale
489
602
  const y = (event.clientY - box.top - padY) / scale
490
- if (x < 0 || y < 0 || x > SIDE_VIEW.width || y > SIDE_VIEW.height) return null
603
+ if (x < 0 || y < 0 || x > view.w || y > view.h) return null
491
604
  return { x: Math.round(x), y: Math.round(y) }
492
605
  }
493
606
  const ime = React.useRef(null)
@@ -503,6 +616,7 @@ window.__ModuleLoader__.load({
503
616
  }
504
617
  const stageRef = stage
505
618
  const onPointerDown = (event) => {
619
+ touched()
506
620
  const point = toView(event)
507
621
  if (point === null) return
508
622
  const field = ime.current
@@ -515,6 +629,7 @@ window.__ModuleLoader__.load({
515
629
  }
516
630
  const movedAt = React.useRef(0)
517
631
  const onPointerMove = (event) => {
632
+ touched()
518
633
  const point = toView(event)
519
634
  if (point === null) return
520
635
  /* Hover is part of a page: menus open under the hand, so the movement
@@ -526,24 +641,46 @@ window.__ModuleLoader__.load({
526
641
  hover(point)
527
642
  }
528
643
  const onWheel = (event) => {
644
+ touched()
529
645
  event.preventDefault()
530
- const point = toView(event) ?? { x: Math.round(SIDE_VIEW.width / 2), y: Math.round(SIDE_VIEW.height / 2) }
646
+ const point = toView(event) ?? { x: Math.round(view.w / 2), y: Math.round(view.h / 2) }
531
647
  post('/sidecar/input', { kind: 'wheel', x: point.x, y: point.y, dx: event.deltaX, dy: event.deltaY })
532
648
  }
649
+ /* The page's keys are the person's keys: shift makes a capital, a
650
+ shortcut stays a shortcut so Ctrl+V pastes and Ctrl+A selects, and the
651
+ key that goes down also goes up so a game can hold it. */
652
+ const modsOf = (event) => (event.altKey === true ? 1 : 0)
653
+ | (event.ctrlKey === true ? 2 : 0)
654
+ | (event.metaKey === true ? 4 : 0)
655
+ | (event.shiftKey === true ? 8 : 0)
656
+ const MODIFIER_KEYS = ['Alt', 'Control', 'Meta', 'Shift', 'AltGraph', 'OS', 'Super', 'Hyper']
657
+ const keyBody = (event, down) => {
658
+ const modifiers = modsOf(event)
659
+ const shortcut = (modifiers & 7) !== 0
660
+ const typing = down === true && event.key.length === 1 && (modifiers & 7) === 0 && event.repeat !== true
661
+ return {
662
+ kind: 'key',
663
+ key: event.key,
664
+ code: event.code || event.key,
665
+ keyCode: event.keyCode || 0,
666
+ modifiers,
667
+ ...(typing ? { text: event.key } : {}),
668
+ ...(down === false ? { down: false } : {}),
669
+ }
670
+ }
533
671
  const onKeyDown = (event) => {
672
+ touched()
534
673
  /* A key still being composed belongs to the IME, not to the page. */
535
674
  if (event.isComposing === true) return
536
- const named = ['Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']
537
- if (named.includes(event.key) === true) {
538
- event.preventDefault()
539
- post('/sidecar/input', { kind: 'key', key: event.key, code: event.code || event.key, keyCode: event.keyCode || 0 }) // ui-copy-ignore
540
- return
541
- }
542
- if (event.metaKey === true || event.ctrlKey === true || event.altKey === true) return
543
- if (event.key != null && event.key.length === 1) {
544
- event.preventDefault()
545
- post('/sidecar/input', { kind: 'text', text: event.key })
546
- }
675
+ if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
676
+ event.preventDefault()
677
+ post('/sidecar/input', event.repeat === true ? { ...keyBody(event, true), repeat: true } : keyBody(event, true))
678
+ }
679
+ const onKeyUp = (event) => {
680
+ if (event.isComposing === true) return
681
+ if (event.key == null || MODIFIER_KEYS.includes(event.key) === true) return
682
+ event.preventDefault()
683
+ post('/sidecar/input', keyBody(event, false))
547
684
  }
548
685
  const goto = () => {
549
686
  const address = String(draft ?? '').trim()
@@ -557,7 +694,7 @@ window.__ModuleLoader__.load({
557
694
  return h('div', { className: 'dshib-side' },
558
695
  h('div', { className: 'dshib-side-note' }, L('side.foreign')))
559
696
  }
560
- const pointer = ptr
697
+ const pointer = person === true || hand === true ? null : ptr
561
698
  const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
562
699
  return h('div', { className: 'dshib-side' },
563
700
  h('div', { className: 'dshib-side-bar' },
@@ -587,6 +724,7 @@ window.__ModuleLoader__.load({
587
724
  onPointerMove,
588
725
  onWheel,
589
726
  onKeyDown,
727
+ onKeyUp,
590
728
  },
591
729
  frame === null && back !== ''
592
730
  ? h('button', {
@@ -627,12 +765,13 @@ window.__ModuleLoader__.load({
627
765
  clearIme()
628
766
  },
629
767
  onKeyDown,
768
+ onKeyUp,
630
769
  }),
631
770
  frame === null ? null : h('img', {
632
771
  className: 'dshib-side-frame',
633
772
  src: `data:image/jpeg;base64,${frame.jpeg}`,
634
- width: Math.max(1, Math.round(SIDE_VIEW.width * scale)),
635
- height: Math.max(1, Math.round(SIDE_VIEW.height * scale)),
773
+ width: Math.max(1, Math.round(view.w * scale)),
774
+ height: Math.max(1, Math.round(view.h * scale)),
636
775
  alt: '', // ui-copy-ignore
637
776
  draggable: false,
638
777
  }),
@@ -4,7 +4,7 @@ Two halves in two processes, and a Chrome that neither of them is.
4
4
 
5
5
  ```
6
6
  server side (index.js, in the DSH process)
7
- sidecar_* tools ──> Sidecar ── CDP over a socket ──> headless Chrome
7
+ browser_* tools ──> Sidecar ── CDP over a socket ──> headless Chrome
8
8
  │
9
9
  HTTP + SSE routes <────┘
10
10
  │
@@ -41,7 +41,7 @@ Other launch flags:
41
41
  | `--use-mock-keychain`, `--password-store=basic` | No keychain prompt on macOS. |
42
42
  | `--remote-allow-origins=*` | The CDP client connects over a plain WebSocket. |
43
43
  | `--disable-blink-features=AutomationControlled` | Sites that sniff for automation get the ordinary path. |
44
- | `--window-size=1440,900`, `--lang=zh-CN` | The viewport the pane mirrors, and a Chinese page locale. |
44
+ | `--window-size=1440,900`, `--lang=zh-CN` | The window the browser starts in, and a Chinese page locale; the layout viewport is set to the column instead. |
45
45
 
46
46
  The binary comes from a fixed list: Google Chrome, Chromium or Canary under `/Applications`,
47
47
  then `/usr/bin/google-chrome` and `/usr/bin/chromium`. Nothing else is probed and there is no
@@ -58,7 +58,7 @@ The browser outlives the plugin:
58
58
  `/json/new?about:blank` as a fallback) and a client connects to it.
59
59
 
60
60
  One page per browser. `apply()` only constructs the `Sidecar`; no process exists until the
61
- first tool call, and `sidecar_state` reports a stopped browser without starting one.
61
+ first tool call, and `browser_state` reports a stopped browser without starting one.
62
62
 
63
63
  Shutdown asks Chrome to close (`Browser.close`), waits briefly, then sends `SIGTERM`, which is
64
64
  the point where Chrome flushes cookies. An idle timer closes the browser after 15 minutes; a
@@ -85,13 +85,16 @@ the CDP socket stays in the server half.
85
85
  ## Mirror
86
86
 
87
87
  The server half starts `Page.startScreencast` with JPEG quality 72, `maxWidth` and `maxHeight`
88
- at 1440×900, `optimizeForSpeed`, `everyNthFrame: 1`, and acknowledges every frame it receives
88
+ at the pane's size, `optimizeForSpeed`, `everyNthFrame: 1`, and acknowledges every frame it receives
89
89
  so Chrome keeps sending. Frames are never queued: with two frames in flight, further frames
90
90
  are dropped and the next one that fits takes their place. A slow reader loses frames, never
91
91
  freshness.
92
92
 
93
- The viewport is pinned with `Emulation.setDeviceMetricsOverride` to the same 1440×900, so a
94
- frame is the whole page as laid out, not a crop.
93
+ The layout viewport follows the column: the pane measures itself, posts the size to
94
+ `/sidecar/viewport` a beat after the resize settles, and the server pins
95
+ `Emulation.setDeviceMetricsOverride` to that size and restarts the screencast at it. So a frame
96
+ is the whole page as laid out at the shape it is shown in — no letterbox bands, no crop. Sizes
97
+ below 320 px or above 1920×1600 are ignored rather than applied as a sliver.
95
98
 
96
99
  If `Browser.getVersion` reports `HeadlessChrome`, the user agent is overridden with an ordinary
97
100
  Chrome string, `acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8'` and `platform: 'MacIntel'`, so a site
@@ -133,7 +136,7 @@ browser without pulling the column away from the person reading it.
133
136
 
134
137
  `sidecarTools()` in `tools.js` builds the 28 definitions and `index.js` registers them on the
135
138
  plugin's tool scope. All of them return one text block. A page read numbers the controls and
136
- keeps those numbers across later reads, so `sidecar_click` by ref means the element the model
139
+ keeps those numbers across later reads, so `browser_click` by ref means the element the model
137
140
  last saw; navigation drops them and the model gets `REF_GONE` rather than a wrong click.
138
141
 
139
142
  Before a ref click or a ref focus, one page-side probe asks what is on top of that control.
@@ -145,7 +148,7 @@ reported, never accepted on the person's behalf. The whole exchange is one `Runt
145
148
  and a possible `mousePressed`/`mouseReleased` pair on an already-open CDP connection, so an
146
149
  uncovered click pays nothing.
147
150
 
148
- `sidecar_do` runs the same probe before a step that names a control — by `ref`, by
151
+ `browser_do` runs the same probe before a step that names a control — by `ref`, by
149
152
  text, or by field label — so a banner the first step clears is gone for the rest of
150
153
  the batch, and the step's own line says it in one clause (`after Accept on a banner,`).
151
154
  A batch therefore pays for one overlay, not one per step.
@@ -154,7 +157,7 @@ An action answers with one line — address, title, focused field — and reads
154
157
  `read: true` is passed. That is what keeps a long batch cheap (see `BENCH.md`).
155
158
 
156
159
  Calls that touch the browser are marked not concurrency-safe; reading state, console and failed
157
- requests is. `sidecar_open`, `sidecar_view`, `sidecar_wait` and `sidecar_shot` carry longer
160
+ requests is. `browser_open`, `browser_view`, `browser_wait` and `browser_shot` carry longer
158
161
  timeouts than the rest.
159
162
 
160
163
  Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
@@ -169,7 +172,7 @@ without asking first when someone wants something done on a site.
169
172
  `Network.enable` is already on for the mirror. A request is kept when it is
170
173
  sent and settled when its response arrives, so the list holds method, status,
171
174
  type and address of what the page actually got — 120 rows, oldest dropped.
172
- `sidecar_network` reads that list without touching the page, which is why it
175
+ `browser_network` reads that list without touching the page, which is why it
173
176
  can answer while a dialog is open.
174
177
 
175
178
  ## Downloads
@@ -177,7 +180,7 @@ can answer while a dialog is open.
177
180
  Chrome is pointed at `<profile>/downloads` when the page starts, so a click
178
181
  that saves a file writes it there instead of nowhere. The folder belongs to
179
182
  the profile: it is the same one on the next launch, and nothing outside the
180
- profile is touched. `sidecar_downloads` lists what is on disk, newest first;
183
+ profile is touched. `browser_downloads` lists what is on disk, newest first;
181
184
  a `.crdownload` still in flight is not listed.
182
185
 
183
186
  ## Frames and popups
@@ -215,4 +218,4 @@ and there is still one page to drive.
215
218
  | `locale/*.json` | Page: pane labels. |
216
219
 
217
220
  Data on disk is confined to `~/.dsh/inner-browser-use/`: `chrome/<profile>` for the browser,
218
- `screenshots/` for `sidecar_shot`.
221
+ `screenshots/` for `browser_shot`.
package/docs/ARTIFACTS.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  - **项目**:会话工作目录的文件夹名,加路径哈希前 6 位,例如 `dsh-inner-browser-use-a1b2c3`。同名文件夹、不同路径不会混。
10
10
  - **会话**:会话 id 去掉 `session-` 后的前 8 位,例如 `72557d6e`。同一项目里两个会话各存各的。
11
- - **种类**:`shots/` 是 `sidecar_shot` 的截图,`pictures/` 是 `sidecar_grab` 抓的图,`pdf/` 是 `sidecar_pdf` 打印的文档。
11
+ - **种类**:`shots/` 是 `browser_shot` 的截图,`pictures/` 是 `browser_grab` 抓的图,`pdf/` 是 `browser_pdf` 打印的文档。
12
12
 
13
13
  文件名默认带时间戳(`shot-20261028-124500.png`),给了 `name` 就用它;名字里的分隔符、连续点、开头点都会被去掉,跳不出这个目录。
14
14