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/docs/VERIFY.md CHANGED
@@ -33,7 +33,7 @@ Expected before any tool call: `"alive":false`, `"profile":"default"`,
33
33
 
34
34
  ## 2. A tool opens its own page
35
35
 
36
- Ask the model `用 sidecar_open 打开 https://www.bing.com`, or call it yourself.
36
+ Ask the model `用 browser_open 打开 https://www.bing.com`, or call it yourself.
37
37
  Expected: the result names the address and title, and this chat's right column
38
38
  shows the picture in a tab called **后台浏览器** or **Background browser**.
39
39
  `doctor.mjs` now reads `attached, headless`, `viewers 1`, frames climbing.
@@ -43,8 +43,8 @@ shows the picture in a tab called **后台浏览器** or **Background browser**.
43
43
  - `"alive":false` with a non-empty `"error"`: Chrome did not start. Check the
44
44
  binary at a listed path, and on macOS that the launch carried
45
45
  `--use-mock-keychain`.
46
- - Frames flat while viewers is 1: the screencast stalled. `sidecar_stop`, then
47
- `sidecar_open` again.
46
+ - Frames flat while viewers is 1: the screencast stalled. `browser_stop`, then
47
+ `browser_open` again.
48
48
  - `"pointer":null`: no tool input yet; it fills on the first click or move.
49
49
 
50
50
  ## 3. Only the chat that asked
@@ -60,18 +60,28 @@ A mirror that opens in every chat means the page half is not comparing
60
60
  - **Hover** the search field: your cursor turns into an I-beam, the black arrow
61
61
  does not move. An arrow that follows the mouse means the pointer paths are
62
62
  crossed.
63
- - **Type** Chinese with `sidecar_type { "text": "…", "submit": true }`: the
63
+ - **Type** Chinese with `browser_type { "text": "…", "submit": true }`: the
64
64
  results page appears and the field holds characters, not boxes.
65
- - **Backspace** with `sidecar_key { "key": "Backspace" }` in a filled field:
65
+ - **Backspace** with `browser_key { "key": "Backspace" }` in a filled field:
66
66
  one character goes. Nothing happening means the key reached the document.
67
- - **Screenshot** with `sidecar_shot {}`: an absolute path under
67
+ - **Screenshot** with `browser_shot {}`: an absolute path under
68
68
  `~/.dsh-inner-browser-use/screenshots`. A black picture means the screencast
69
69
  was not running when the frame was taken.
70
+ - **The whole keyboard, on this machine**: `npm run keys` starts a real
71
+ background Chrome, presses all 150 keys into a field, and checks what the page
72
+ reported for each one (`key`, `code`, `keyCode`, shift) plus what the field
73
+ typed. It then runs copy / cut / paste / select-all / undo / redo, caret keys,
74
+ Tab focus, Enter, and a 180 ms hold. One JSON line, exit code 0 when every key
75
+ matched. CI runs it on macOS and Windows in the `keys` job.
76
+ - **A shortcut by hand**: type `Hi`, press `browser_key { "key": "Mod+a" }`,
77
+ type `X`: the field holds `X` and nothing before it. `Mod` is Command on macOS
78
+ and Control elsewhere; `Ctrl+C/X/V/A/Z/Y` and `Ctrl+Shift+Z` run the same
79
+ editing commands a person would.
70
80
 
71
81
  ## 5. Stop, then open again
72
82
 
73
- `sidecar_stop` reads `"alive":false` and leaves the profile alone.
74
- `sidecar_open https://www.bing.com` brings the same profile back, still signed
83
+ `browser_stop` reads `"alive":false` and leaves the profile alone.
84
+ `browser_open https://www.bing.com` brings the same profile back, still signed
75
85
  in from the first pass.
76
86
 
77
87
  Signed out after the restart: Chrome came up on another profile, or was killed
@@ -140,13 +150,13 @@ broke. Two of them cover a cross-site frame, which Chrome runs in its own
140
150
  process. Three cover a control under an overlay: the click clears a cookie
141
151
  banner and lands (`under clicked`), typing into a field under the same banner
142
152
  works, and a modal that offers to delete the account is reported without
143
- anything being pressed. Two more drive the same overlay through `sidecar_do`:
153
+ anything being pressed. Two more drive the same overlay through `browser_do`:
144
154
  a batch step clears the banner before it clicks, and a batch step fills a field
145
155
  that was under it. One more counts the recoveries: after the redraw check, the
146
156
  state line says at least one ref was followed to its new number. Three cover a
147
157
  hand on the mirror: while the person is using it a read still works
148
158
  (`hold-allows-read`), an action is refused and names both ways out
149
- (`hold-refuses-action`), and `sidecar_resume` hands the page back so the same
159
+ (`hold-refuses-action`), and `browser_resume` hands the page back so the same
150
160
  click goes through (`resume-lets-go`).
151
161
 
152
162
  ## Who the mirror serves
package/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * learns no port and no secret. // ui-copy-ignore
21
21
  */
22
22
 
23
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
24
  import { homedir } from 'node:os'
25
25
  import { join } from 'node:path'
26
26
  import { Sidecar } from './sidecar.js?v=1'
@@ -106,6 +106,17 @@ export class Bridge {
106
106
  this.wanted = { session: kept.session, at: kept.at }
107
107
  }
108
108
 
109
+ /**
110
+ * A chat is the chat that asked, and nothing else. Guessing at an alias once
111
+ * put the mirror in a stranger's column: two conversations are never the same
112
+ * conversation, however few pages happen to be on screen.
113
+ * @param {string} session - whatever id the caller has.
114
+ * @returns {string} the same id.
115
+ */
116
+ canon(session) {
117
+ return String(session ?? '')
118
+ }
119
+
109
120
  /**
110
121
  * Give the mirror to one chat and take it away from every earlier one.
111
122
  * @param {string} sessionId - chat that asked for it.
@@ -123,11 +134,12 @@ export class Bridge {
123
134
  * @returns {boolean} whether a live tab took the frame.
124
135
  */
125
136
  want(sessionId, at = Date.now()) {
126
- if (sessionId === '') return false
127
- this.claim(sessionId)
128
- this.wanted = { session: sessionId, at }
129
- if (this.persist === true) writeMirror({ owner: this.owner, session: sessionId, at })
130
- return this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at })
137
+ const id = this.canon(sessionId)
138
+ if (id === '') return false
139
+ this.claim(id)
140
+ this.wanted = { session: id, at }
141
+ if (this.persist === true) writeMirror({ owner: this.owner, session: id, at })
142
+ return this.push(id, { type: 'sidecar-wanted', session: id, at })
131
143
  }
132
144
 
133
145
  /**
@@ -195,7 +207,7 @@ export class Bridge {
195
207
  /** Page read: address, title, controls, then text, as JSON the Host parses. */ // ui-copy-ignore
196
208
  /* The read model and the tool surface live in their own files: this module is
197
209
  the Host half (routes, bridge, lifetimes), not the model's vocabulary. */ // ui-copy-ignore
198
- import { KEYS, sidecarTools, sessionScope, workScope } from './tools.js?v=1'
210
+ import { keyEvent, sidecarTools, sessionScope, workScope } from './tools.js?v=1'
199
211
 
200
212
  /**
201
213
  * A state poll may pick up a browser that outlived a reload, and only for the
@@ -252,6 +264,36 @@ export function shouldAdoptOnState(said, chat, owner) {
252
264
  return said?.alive === false && chat !== '' && owner === chat
253
265
  }
254
266
 
267
+ /**
268
+ * Whether a running browser still needs its column told to draw it.
269
+ * @param {{alive: boolean, owner: string, wanted: string, listeners: number}} what - current facts.
270
+ * @returns {boolean} true when the owner is known, the browser runs, and nothing listens.
271
+ */
272
+ export function wantsMirrorAgain(what) {
273
+ if (what?.alive !== true) return false
274
+ const owner = String(what?.owner ?? '')
275
+ if (owner === '') return false
276
+ if (String(what?.wanted ?? '') !== owner) return false
277
+ return Number(what?.listeners ?? 0) <= 0
278
+ }
279
+
280
+ /**
281
+ * Whether asking again can achieve anything. A pane that is polling this very
282
+ * endpoint is awake and already knows the frames are missing, so a second ask
283
+ * every few seconds only writes log lines; a pane that went quiet may have lost
284
+ * the stream and does need one. One ask per gap at most.
285
+ * @param {any} what - { askedAgo, sinceLast, quiet, gap } in milliseconds.
286
+ * @returns {boolean} true when the ask should be sent now.
287
+ */
288
+ export function nudgeDue(what) {
289
+ const askedAgo = Number(what?.askedAgo ?? -1)
290
+ const sinceLast = Number(what?.sinceLast ?? Infinity)
291
+ const quiet = Number(what?.quiet ?? 15_000)
292
+ const gap = Number(what?.gap ?? 10_000)
293
+ if (!Number.isFinite(askedAgo) || askedAgo < quiet) return false
294
+ return sinceLast >= gap
295
+ }
296
+
255
297
  export const name = 'dsh-inner-browser-use'
256
298
  export const inject = ['webServer']
257
299
 
@@ -268,6 +310,31 @@ export function apply(ctx) {
268
310
  /** @type {{refs: any[], at: number, last?: any}} */
269
311
  const sidecarRefs = { refs: [], at: 0 }
270
312
  ctx.effect(() => () => bridge.dispose(), 'dsh-inner-browser-use: bridge lifetime')
313
+ /* A live picture that nobody is drawing is the one failure people notice
314
+ first: the browser is fine, the column just lost its pane. The chat that
315
+ owns the browser is asked again, on a slow beat, until the frames stream
316
+ has a listener — the client keeps the rule that only the mounted chat's
317
+ column may open. */
318
+ /** @type {Map<string, number>} when each chat last polled for state. */
319
+ const lastAsk = new Map()
320
+ ctx.effect(() => {
321
+ let last = 0
322
+ const timer = setInterval(() => {
323
+ try {
324
+ const owner = bridge.owner
325
+ const listeners = sidecar.page?.frames?.size ?? 0
326
+ const again = wantsMirrorAgain({ alive: sidecar.page !== null && sidecar.page !== undefined, owner, wanted: bridge.wanted.session, listeners }) // ui-copy-ignore
327
+ if (again !== true) return
328
+ const due = nudgeDue({ askedAgo: Date.now() - (lastAsk.get(owner) ?? 0), sinceLast: Date.now() - last }) // ui-copy-ignore
329
+ if (due !== true) return
330
+ last = Date.now()
331
+ bridge.want(owner)
332
+ appendFileSync(join(STATE_DIR, 'asks.log'), `${new Date().toISOString()} asked ${owner.slice(-8)} to draw the mirror again\n`)
333
+ } catch {}
334
+ }, 4000)
335
+ timer.unref?.()
336
+ return () => clearInterval(timer)
337
+ }, 'dsh-inner-browser-use: mirror keepalive')
271
338
  /* Closing the browser cleanly on quit is what keeps a sign-in alive: a
272
339
  killed Chrome never writes the cookies a page set this minute. */
273
340
  ctx.effect(() => {
@@ -285,6 +352,10 @@ export function apply(ctx) {
285
352
  /** @param {import('node:http').IncomingMessage} req - request. @param {import('node:http').ServerResponse} res - response. */
286
353
  /** @type {{at: number, path: string, session: string}[]} the last few calls the column made. */
287
354
  const recent = []
355
+ /* Whether the pane is asking at all is the difference between "the browser
356
+ stopped" and "nothing is drawing it": the poll is every 2.5 s, so a chat
357
+ that asks is a chat with a mounted pane. Kept on disk because the answer
358
+ has to survive a plugin reload, which is when the question is asked. */
288
359
  const handler = async (req, res) => {
289
360
  const url = new URL(req.url ?? ROUTE, 'http://dsh.invalid')
290
361
  const path = url.pathname.slice(ROUTE.length) || '/'
@@ -334,6 +405,8 @@ export function apply(ctx) {
334
405
  const asked = String(url.searchParams.get('session') ?? '')
335
406
  const watching = asked !== '' ? asked : sessionId
336
407
  if (held(watching) && refuse()) return
408
+ /* Only the owner's own page may stream the picture. */
409
+ const streaming = watching
337
410
  res.writeHead(200, {
338
411
  'content-type': 'text/event-stream; charset=utf-8',
339
412
  'cache-control': 'no-cache, no-transform',
@@ -353,7 +426,7 @@ export function apply(ctx) {
353
426
  page wait: the newest picture is the only one worth sending. */
354
427
  if (res.writableLength > 3 * 1024 * 1024) return
355
428
  res.write(`data: ${JSON.stringify(frame)}\n\n`)
356
- }, watching)
429
+ }, streaming)
357
430
  const info = await sidecar.state()
358
431
  /* The pane needs one address to offer back when this chat's own page
359
432
  went away; the state route answers the same question for the tab
@@ -426,6 +499,15 @@ export function apply(ctx) {
426
499
  said = await sidecar.state()
427
500
  } catch {}
428
501
  }
502
+ if (chat !== '') {
503
+ const before = lastAsk.get(chat) ?? 0
504
+ if (Date.now() - before > 2000) {
505
+ lastAsk.set(chat, Date.now())
506
+ try {
507
+ appendFileSync(join(STATE_DIR, 'asks.log'), `${new Date().toISOString()} ${chat.slice(-8)} alive=${String(said?.alive === true)} viewers=${String(said?.viewers ?? 0)}\n`)
508
+ } catch {}
509
+ }
510
+ }
429
511
  json(200, { ...said, ...(chat === '' ? {} : { chat: mineChat ?? (await sidecar.chatState(chat)) }), owner: bridge.owner, chats: [...bridge.seats.keys()], recent: mine })
430
512
  return
431
513
  }
@@ -463,6 +545,35 @@ export function apply(ctx) {
463
545
  return
464
546
  }
465
547
 
548
+ if (path === '/sidecar/viewport' && req.method === 'POST') {
549
+ /* The pane tells the page how big it is, so the picture fills the column
550
+ instead of being letterboxed inside it. Only the owner may resize what
551
+ its own mirror shows. */
552
+ let body
553
+ try {
554
+ body = JSON.parse(await readBody(req))
555
+ } catch {
556
+ json(400, { ok: false, text: 'bad frame' }) // ui-copy-ignore
557
+ return
558
+ }
559
+ const actor = acting(body)
560
+ if (actor === '') {
561
+ json(403, { ok: false, text: 'this chat does not own the browser' }) // ui-copy-ignore
562
+ return
563
+ }
564
+ try {
565
+ const page = sidecar.page
566
+ if (page === null || page === undefined) {
567
+ json(409, { ok: false, text: 'the browser is not open' }) // ui-copy-ignore
568
+ return
569
+ }
570
+ json(200, { ok: true, view: await page.setViewport(body.w, body.h) })
571
+ } catch (error) {
572
+ json(500, { ok: false, text: String(error?.message ?? error) })
573
+ }
574
+ return
575
+ }
576
+
466
577
  if (path === '/sidecar/input' && req.method === 'POST') {
467
578
  let body
468
579
  try {
@@ -495,21 +606,26 @@ export function apply(ctx) {
495
606
  } else if (kind === 'wheel') await page.wheel({ x, y, dx: Number(body.dx ?? 0), dy: Number(body.dy ?? 0) })
496
607
  else if (kind === 'text') await page.insertText(String(body.text ?? ''))
497
608
  else if (kind === 'key') {
498
- const name = String(body.key ?? '')
499
- const known = KEYS[name]
500
- const spec = {
501
- key: name,
502
- code: String(body.code ?? known?.code ?? name),
503
- keyCode: Number(body.keyCode ?? 0) || known?.keyCode || 0,
504
- text: body.text === undefined ? known?.text : String(body.text),
505
- modifiers: Number(body.modifiers ?? 0),
506
- }
507
- await page.key('keyDown', spec)
508
- await page.key('keyUp', { ...spec, text: undefined })
609
+ const key = keyEvent(body)
610
+ if (key.down !== true) await page.key('keyUp', { ...key.spec, text: undefined })
611
+ else if (key.repeat === true) {
612
+ /* Typing repeat: the page draws a held key as a fresh keydown. */
613
+ await page.key('keyDown', { ...key.spec, autoRepeat: true })
614
+ } else if (key.command === null) await page.key('keyDown', key.spec)
615
+ else await page.key('keyDown', { ...key.spec, text: undefined, commands: [key.command] })
509
616
  }
510
617
  /* Hovering and moving are looking, not acting: only real input counts as
511
618
  the person taking the page. */
512
- if (kind !== 'hover' && kind !== 'move') sidecar.humanUse({ kind, x, y })
619
+ if (kind !== 'hover' && kind !== 'move') {
620
+ sidecar.humanUse({ kind, x, y })
621
+ /* The arrow goes now, not on the next repaint: a still page may not
622
+ paint again for a second and a half. */
623
+ try {
624
+ page.announceHold()
625
+ } catch {
626
+ /* the stream is gone; the next frame carries the same answer */
627
+ }
628
+ }
513
629
  json(200, cursor === undefined ? { ok: true } : { ok: true, cursor })
514
630
  } catch (error) {
515
631
  json(500, { ok: false, text: String(error?.message ?? error) })
@@ -668,11 +784,15 @@ export function apply(ctx) {
668
784
  name: 'browser-in-this-chat',
669
785
  order: 858,
670
786
  text: [
671
- '## 网页任务:用 sidecar 浏览器做,不问直接开始',
787
+ '## 网页任务:人明确点名才开右栏浏览器',
788
+ '右栏「后台浏览器」是一台独立的后台 Chrome,模型能点、能读、能执行脚本;画面只出现在本会话右栏,不占别的会话。它只做「必须动手」的活,默认不开。',
672
789
  '',
673
- '人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成 …」时,直接用 `sidecar_*` 工具;不要问要不要打开,也不要另起一个外部 Chrome。它的画面会出现在本会话右栏,做完保留,不主动关。',
674
- '先接着用已有的页:`sidecar_view`(拿编号控件与可见文字)→ `sidecar_click` / `sidecar_type` → 再 `sidecar_view` 直到出现要的结果;页面要等就重复读,不 sleep。`sidecar_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
675
- '要保存页面样子用 `sidecar_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
790
+ '只有人明确说要看网页/用浏览器时才开始:「用网页端 / 在浏览器里 / 打开某个网站 / 上 ChatGPT 生成 / 帮我在这个页面点…」。人只给了链接、没说要用浏览器时,先用 `web_fetch` 或 envoy 读;读不到(登录墙、空壳、要交互)就说明原因再问一句,不要自己开。',
791
+ '不要用它:读公开文章、单个链接正文、文档、搜索结果摘要、GitHub 仓库或 PR 页面——这些能用 `web_fetch` / envoy 读就不用浏览器;一打开就是一个后台 Chrome 进程加一个页签,会话多了很占内存。',
792
+ '人点名之后不问、不另起外部 Chrome;画面留在本会话右栏,做完保留,不主动关。',
793
+ '先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
794
+ '页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
795
+ '要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
676
796
  ].join('\n'),
677
797
  }), 'dsh-inner-browser-use: system prompt section')
678
798
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.34.28",
3
+ "version": "0.34.95",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -22,6 +22,11 @@
22
22
  "cordis.patch.yml",
23
23
  "icon.svg",
24
24
  "scripts/doctor.mjs",
25
+ "scripts/check-flash-window.mjs",
26
+ "scripts/check-flash-mirror.mjs",
27
+ "scripts/check-flash-local.mjs",
28
+ "scripts/check-flash-tools.mjs",
29
+ "runtime/",
25
30
  "docs/",
26
31
  "README.md",
27
32
  "README.zh.md",
@@ -43,12 +48,21 @@
43
48
  "dsh": ">=0.2.0"
44
49
  },
45
50
  "scripts": {
46
- "test": "node --test test/*.test.mjs",
51
+ "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs",
47
52
  "doctor": "node scripts/doctor.mjs",
48
53
  "breadth": "node scripts/breadth.mjs",
49
54
  "live": "node scripts/live.mjs",
50
55
  "adopt": "node scripts/check-adopt.mjs",
51
- "game": "node scripts/check-game.mjs"
56
+ "game": "node scripts/check-game.mjs",
57
+ "modes": "node scripts/check-modes.mjs",
58
+ "ready": "node scripts/check-prepare.mjs",
59
+ "pdf": "node scripts/check-pdf.mjs",
60
+ "swf": "node scripts/swf-inspect.mjs",
61
+ "flash-hidden": "node scripts/check-flash-window.mjs",
62
+ "flash-mirror": "node scripts/check-flash-mirror.mjs",
63
+ "flash-tools": "node scripts/check-flash-tools.mjs",
64
+ "flash-local": "node scripts/check-flash-local.mjs",
65
+ "keys": "node scripts/keyboard.mjs"
52
66
  },
53
67
  "license": "MIT",
54
68
  "author": {