dsh-inner-browser-use 0.35.0 → 0.35.2

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.zh.md CHANGED
@@ -18,7 +18,7 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
18
18
  | `browser_type` | 往焦点输入框输入文字,也可以先点一个编号控件。中文整段送进去。`submit: true` 回车提交。 |
19
19
  | `browser_key` | 按一个键:Enter、Tab、Escape、Backspace、Delete、上下左右、Home、End、PageUp、PageDown。 |
20
20
  | `browser_scroll` | 在页面中心滚轮,也可以指定 `x`/`y`。`dy: 600` 约一屏。 |
21
- | `browser_shot` | 存一张原尺寸 PNG 到 `~/.dsh-inner-browser-use/screenshots`,返回路径。 |
21
+ | `browser_shot` | 存一张 PNG 到 `~/.dsh/inner-browser-use/screenshots`,返回路径;`region: [x, y, w, h]` 只截一块(游戏、图表、看局部)。 |
22
22
  | `browser_state` | 报浏览器在不在跑、用的哪个 profile、地址、标题,以及有几个右栏在看它。 |
23
23
 
24
24
  点、输入、按键之后的那一行会多一句结果:`· navigated to …`(换页了)、`· content changed`(页内有变化)、`· no visible change`(点了没动)、Flash 页则是 `· screen updated` / `· no frame change`;`read: true` 时不再多说。
package/index.js CHANGED
@@ -117,6 +117,11 @@ export class Bridge {
117
117
  is not on screen has no stream, so a push at ask time is lost; remembering
118
118
  the ask is what makes the mirror appear when the person returns. */
119
119
  this.wanted = { session: '', at: 0 }
120
+ /* Frame streams by chat. A close can arrive while a stream is still
121
+ attaching, so closing the mirror hangs up what that chat started: a
122
+ browser nobody watches is what lets it go to rest. */
123
+ /** @type {Map<string, Set<() => void>>} */
124
+ this.viewers = new Map()
120
125
  /* A running plugin keeps the note; a bare Bridge in a test does not. */
121
126
  this.persist = options.persist === true
122
127
  const kept = this.persist ? readMirror() : { owner: '', session: '', at: 0 }
@@ -208,6 +213,42 @@ export class Bridge {
208
213
  return [...this.seats.keys()]
209
214
  }
210
215
 
216
+ /**
217
+ * Remember one open frame stream so the chat can hang it up later.
218
+ * @param {string} sessionId - chat drawing the mirror.
219
+ * @param {() => void} off - ends that one stream.
220
+ */
221
+ watch(sessionId, off) {
222
+ const id = this.canon(sessionId)
223
+ if (id === '' || typeof off !== 'function') return
224
+ const set = this.viewers.get(id) ?? new Set()
225
+ set.add(off)
226
+ /* A tab that reloads faster than its old socket closes must not pile up
227
+ streams: the oldest goes, which is the one no page is reading. */
228
+ while (set.size > 4) {
229
+ const oldest = set.values().next().value
230
+ set.delete(oldest)
231
+ try { oldest() } catch { /* a stream already gone is already gone */ }
232
+ }
233
+ this.viewers.set(id, set)
234
+ }
235
+
236
+ /**
237
+ * Hang up every frame stream one chat started.
238
+ * @param {string} sessionId - chat whose streams go away.
239
+ * @returns {number} how many streams were hanging up.
240
+ */
241
+ unwatch(sessionId) {
242
+ const id = this.canon(sessionId)
243
+ const set = this.viewers.get(id)
244
+ if (set === undefined) return 0
245
+ this.viewers.delete(id)
246
+ for (const off of set) {
247
+ try { off() } catch { /* a stream already gone is already gone */ }
248
+ }
249
+ return set.size
250
+ }
251
+
211
252
  /**
212
253
  * Give the mirror up: the person closed the column, so the chat stops owning
213
254
  * it and no keepalive may ask for it again. Ownership is what the column
@@ -219,6 +260,9 @@ export class Bridge {
219
260
  const id = this.canon(sessionId)
220
261
  if (id === '') return false
221
262
  const held = this.owner === id || this.wanted.session === id
263
+ /* The picture goes with the ask: a stream left attached after the column
264
+ closed keeps the browser awake with nobody looking at it. */
265
+ this.unwatch(id)
222
266
  this.wanted = { session: '', at: 0 }
223
267
  if (this.owner === id) this.owner = ''
224
268
  if (this.persist === true) writeMirror({ owner: this.owner, session: '', at: 0 })
@@ -229,6 +273,7 @@ export class Bridge {
229
273
  dispose() {
230
274
  for (const res of this.seats.values()) res.end()
231
275
  this.seats.clear()
276
+ for (const id of [...this.viewers.keys()]) this.unwatch(id)
232
277
  }
233
278
  }
234
279
 
@@ -447,17 +492,33 @@ export function apply(ctx) {
447
492
  res.socket?.setNoDelay?.(true)
448
493
  res.write('retry: 2000\n\n')
449
494
  /** @type {() => void} */
450
- let detach = () => {}
451
- const drop = () => detach()
495
+ let off = () => {}
496
+ /* The stream can end while `watch` is still waiting for a browser to come
497
+ up: that close has to be remembered, or the sink added a second later is
498
+ never taken away and the browser is kept awake with nobody looking. */
499
+ let shut = false
500
+ let done = false
501
+ const hang = () => {
502
+ if (done) return
503
+ done = true
504
+ try { off() } catch { /* a stream already gone is already gone */ }
505
+ try { res.end() } catch { /* the socket may be the thing that closed */ }
506
+ }
507
+ const drop = () => {
508
+ shut = true
509
+ hang()
510
+ }
452
511
  req.on('close', drop)
453
512
  res.on('error', drop)
454
513
  try {
455
- detach = await sidecar.watch((frame) => {
514
+ off = await sidecar.watch((frame) => {
456
515
  /* A viewer that cannot keep up loses frames instead of making the
457
516
  page wait: the newest picture is the only one worth sending. */
458
517
  if (res.writableLength > 3 * 1024 * 1024) return
459
518
  res.write(`data: ${JSON.stringify(frame)}\n\n`)
460
519
  }, streaming)
520
+ if (shut) hang()
521
+ else if (watching !== '') bridge.watch(watching, hang)
461
522
  const info = await sidecar.state()
462
523
  /* The pane needs one address to offer back when this chat's own page
463
524
  went away; the state route answers the same question for the tab
@@ -725,6 +786,13 @@ export function apply(ctx) {
725
786
  } else {
726
787
  await sidecar.useTab(at, actor)
727
788
  }
789
+ /* Closing the last page is this chat saying it is done with the mirror.
790
+ An owner left behind is what the next poll reads as "put my tab back":
791
+ the pane reappeared right after the person closed it. */
792
+ if (body.op === 'close') {
793
+ const left = await sidecar.chatState(actor)
794
+ if (left?.alive !== true && bridge.forget(actor) && sidecar.watched() === 0) sidecar.touch(REST_MS)
795
+ }
728
796
  const tabs = await sidecar.windowsFor(actor)
729
797
  json(200, { ok: true, tabs })
730
798
  } catch (error) {
@@ -876,6 +944,7 @@ export function apply(ctx) {
876
944
  '先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
877
945
  '页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
878
946
  '要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
947
+ '游戏、canvas、Flash:画面自己画的地方,`browser_view` 和 `browser_read` 读不出里面是什么。先 `browser_prepare` 拿画面位置、帧率、按键是否到达和下一步;之后看 `browser_shot` 的图定位置,按坐标点、按住、按组合键,再拍一张看画面变了没有——变了就是这一步生效了。当年用 Flash 做的游戏用 `browser_open` 带 `flash:true` 在栏内跑,不装播放器、不开窗口。',
879
948
  ].join('\n'),
880
949
  }), 'dsh-inner-browser-use: system prompt section')
881
950
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
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",
package/sidecar.js CHANGED
@@ -826,18 +826,17 @@ export class SidecarPage {
826
826
  probe is only for the case where the click never made one, so it gets a
827
827
  single short look instead of the old half-second of polling. */
828
828
  if (target === null) {
829
- /* Look, wait for the announcement, look again: a new tab is announced
830
- before the browser lists it, so one look is not enough. */
831
- const deadline = Date.now() + POPUP_GRACE_MS
832
- while (target === null && Date.now() < deadline) {
833
- const found = await this.findPopup(foreign)
834
- if (found !== null) {
835
- target = found
836
- break
829
+ /* One look at the tab list answers the common case — a click that opened
830
+ nothing. When it says nothing, the announcement is the cheaper door:
831
+ a tab exists a moment before the browser lists it, so the wait ends on
832
+ the event instead of running out the window. */
833
+ target = await this.findPopup(foreign)
834
+ if (target === null) {
835
+ await this.waitPopup(Math.min(8, POPUP_GRACE_MS))
836
+ if (this.opened !== null && this.opened !== undefined && Date.now() - Number(this.opened.at ?? 0) < 2000) {
837
+ target = { targetId: String(this.opened.targetId), url: String(this.opened.url) }
837
838
  }
838
- const left = deadline - Date.now()
839
- if (left <= 0) break
840
- await this.waitPopup(Math.min(8, left))
839
+ this.opened = null
841
840
  }
842
841
  }
843
842
  if (target === null) {
package/tools.js CHANGED
@@ -310,7 +310,17 @@ export async function outcome(page, before) {
310
310
  const [wasTitle, wasUrl, wasText, wasNodes] = before.split('|')
311
311
  const [nowTitle, nowUrl, nowText, nowNodes] = after.split('|')
312
312
  if (after === before) return ' · no visible change'
313
- if (nowUrl !== wasUrl) return ` · navigated to ${String(nowUrl).slice(0, 300)}`
313
+ if (nowUrl !== wasUrl) {
314
+ /* The brief on the same line already names the address this step went to.
315
+ What it cannot say is whether the page moved within itself, so a jump to
316
+ another spot on the same address is said as the spot alone. */
317
+ const was = String(wasUrl ?? '')
318
+ const now = String(nowUrl ?? '')
319
+ const base = (value) => (value.includes('#') ? value.slice(0, value.indexOf('#')) : value)
320
+ const spot = now.includes('#') ? now.slice(now.indexOf('#')) : ''
321
+ if (spot !== '' && base(was) === base(now)) return ` · ${spot.slice(0, 120)}`
322
+ return ' · new page'
323
+ }
314
324
  const words = Math.abs(Number(nowText ?? 0) - Number(wasText ?? 0))
315
325
  const nodes = Math.abs(Number(nowNodes ?? 0) - Number(wasNodes ?? 0))
316
326
  if (nowTitle !== wasTitle) return ' · page moved on'
@@ -352,9 +362,27 @@ export function withoutSeenAddress(page, url, text) {
352
362
  return body.slice(first.length + 1)
353
363
  }
354
364
 
365
+ /** The focus line repeats the control the call named: say the name once. */
366
+ export function dropSameFocus(line, args) {
367
+ const squeezed = (value) => String(value ?? '').toLowerCase().replace(/\s+/g, '')
368
+ const named = squeezed(args?.text ?? args?.name ?? args?.ref ?? '')
369
+ if (named === '') return line
370
+ const at = line.indexOf(' · focus ')
371
+ if (at < 0) return line
372
+ const head = line.slice(0, at)
373
+ const parts = line.slice(at + ' · focus '.length).split(' · ')
374
+ const said = String(parts[0] ?? '')
375
+ /* A field carries a value the words do not: that part is news, so it stays. */
376
+ if (said.includes(' = ')) return line
377
+ const label = squeezed(said)
378
+ if (label !== named && !label.includes(named) && !named.includes(label)) return line
379
+ const rest = parts.slice(1)
380
+ return rest.length === 0 ? head : `${head} · ${rest.join(' · ')}`
381
+ }
382
+
355
383
  async function acted(page, args, look, label) {
356
384
  if (args?.read === true) return await look()
357
- return `ok ${label} · ${oncePerPage(page, await brief(page))}`
385
+ return `ok ${label} · ${dropSameFocus(oncePerPage(page, await brief(page)), args)}`
358
386
  }
359
387
 
360
388
  /**
@@ -2710,13 +2738,14 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2710
2738
  },
2711
2739
  {
2712
2740
  name: 'browser_shot', // ui-copy-ignore
2713
- description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control.',
2741
+ description: 'Save a PNG of the sidecar browser into the session folder and return its absolute path; when the current model accepts images the picture comes back in the same call, so a captcha, a chart or a broken layout can be looked at instead of guessed. Default is the visible screen; full takes the whole page, ref takes one control, region takes one box of it.',
2714
2742
  parameters: {
2715
2743
  type: 'object', // ui-copy-ignore
2716
2744
  properties: {
2717
2745
  name: { type: 'string', description: 'File name without extension.' },
2718
2746
  full: { type: 'boolean', description: 'The whole page, not just the screen.' },
2719
2747
  ref: { type: 'integer', description: 'One control from browser_view.' },
2748
+ region: { type: 'array', items: { type: 'number' }, description: 'One box instead of the whole screen: [x, y, width, height] in page pixels, the same numbers browser_prepare and browser_find report.' },
2720
2749
  scale: { type: 'integer', description: 'Pixel ratio. Default 1; 2 is sharper and heavier.' },
2721
2750
  },
2722
2751
  additionalProperties: false,
@@ -2738,6 +2767,17 @@ export function sidecarTools(sidecar, refs, host = {}) { // ui-copy-ignore
2738
2767
  const said = await withRead(async (page) => {
2739
2768
  const scale = Math.max(1, Math.min(3, Number(args?.scale ?? 1)))
2740
2769
  let clip = undefined
2770
+ /* A game or a chart does not need the whole screen: one box costs a
2771
+ fraction of the picture, and a picture is billed by area. */
2772
+ if (args?.ref === undefined && Array.isArray(args?.region) && args.region.length === 4) {
2773
+ const box = args.region.map((part) => Number(part))
2774
+ clip = {
2775
+ x: Math.max(0, Math.round(Number.isFinite(box[0]) ? box[0] : 0)),
2776
+ y: Math.max(0, Math.round(Number.isFinite(box[1]) ? box[1] : 0)),
2777
+ width: Math.max(8, Math.min(4000, Math.round(Number.isFinite(box[2]) ? box[2] : 0) || 8)),
2778
+ height: Math.max(8, Math.min(4000, Math.round(Number.isFinite(box[3]) ? box[3] : 0) || 8)),
2779
+ }
2780
+ }
2741
2781
  if (args?.ref !== undefined) {
2742
2782
  const spot = await findAgain(page, Number(args.ref))
2743
2783
  if (spot.gone === true) return spot.why ?? REF_GONE