dsh-inner-browser-use 0.34.28 → 0.34.96

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/ROADMAP.md CHANGED
@@ -50,7 +50,7 @@ stealing the column from other chats.
50
50
 
51
51
  ## Phase 2 — one call, many steps (target: a login flow in 1 tool call)
52
52
 
53
- - `sidecar_do`: ordered steps with per-step waits (`wait_text`, `wait_gone`,
53
+ - `browser_do`: ordered steps with per-step waits (`wait_text`, `wait_gone`,
54
54
  `wait_idle`, `wait_url`), one result: what changed, the final URL, tokens spent
55
55
  on one read instead of five.
56
56
  - Auto-wait after every action: navigation start/finish, DOM quiet, and
@@ -65,7 +65,7 @@ stealing the column from other chats.
65
65
  - File upload (`DOM.setFileInputFiles`), download capture with a returned path,
66
66
  print-to-PDF, full-page and element screenshots.
67
67
  - Tabs: new / switch / close / list; iframes addressed by frame id; dialog
68
- accept/dismiss; `sidecar_eval` for the last 1% of cases; cookies read/write for
68
+ accept/dismiss; `browser_eval` for the last 1% of cases; cookies read/write for
69
69
  debugging; console + failed requests surfaced in the state read.
70
70
  - Keyboard: modifiers, shortcuts, clipboard paste, IME composition unchanged.
71
71
 
@@ -86,14 +86,14 @@ stealing the column from other chats.
86
86
 
87
87
  - Human takeover (0.17.1): real input in the pane (click / text / wheel / key)
88
88
  holds the agent's next action for 6 s and says so; hover and move do not count;
89
- reads stay open; `sidecar_resume` takes the page back at once.
89
+ reads stay open; `browser_resume` takes the page back at once.
90
90
  - Guarded actions (upload, download, close tab, buy-ish submits) can require a
91
91
  confirm flag. Shipped in 0.14.0 for the narrow case that matters most: an
92
92
  overlay in the way is cleared only when its own way out is a safe word
93
93
  (accept / agree / close / 同意 / 关闭 …) and the overlay says nothing about
94
94
  deleting, paying or signing out; otherwise it is reported, never taken.
95
95
  Since 0.15.0 the same clearing runs before every control-naming step of a
96
- `sidecar_do` batch, so a multi-step flow through a cookie wall fails zero times.
96
+ `browser_do` batch, so a multi-step flow through a cookie wall fails zero times.
97
97
  - Crash recovery: attach to a still-running Chrome, rebuild the page, keep the
98
98
  profile; a killed host never leaves a zombie Chrome.
99
99
  - Every failure names what happened, what state the page is in, and the next
@@ -124,10 +124,10 @@ finding the control again by identity (kind + exact words + link destination),
124
124
  refusing when the words are not unique. Measured at 462 ms against 445 ms for a
125
125
  fresh control — 17 ms when needed, nothing when not.
126
126
 
127
- Since 0.16.1 the count is visible: `sidecar_state` reports `refs followed N`,
127
+ Since 0.16.1 the count is visible: `browser_state` reports `refs followed N`,
128
128
  and breadth asserts it moved after a redraw, so the next rounds can tell from
129
129
  real sites whether redrawing is frequent enough to justify more work here.
130
- Next in this direction: the same identity recovery for `sidecar_do` steps that
130
+ Next in this direction: the same identity recovery for `browser_do` steps that
131
131
  name a field by its label when the label element is rebuilt (today the step
132
132
  falls back to a fresh read).
133
133
 
@@ -142,7 +142,7 @@ then repeat the loop with whatever the numbers say is worst.
142
142
  Two things were wrong at the edge of the tool surface, and both were only
143
143
  reachable on real sites.
144
144
 
145
- The first was that the shape of the browser could not change. `sidecar_open`
145
+ The first was that the shape of the browser could not change. `browser_open`
146
146
  accepted `headless: false` and answered success; when a browser was already
147
147
  running nothing happened, because the flag was only read at launch. A site that
148
148
  refuses a windowless browser (chatgpt.com on this machine: a "just a moment"
@@ -150,7 +150,7 @@ page forever, versus a login page in a windowed one) therefore had no way out
150
150
  except killing a process by hand. Since 0.17.0 a request for the other shape
151
151
  restarts the browser on the same profile, cookie jar and address — 21.6 s
152
152
  windowless → windowed, 22.1 s back, 0 ms when the shape already matches — and
153
- both `sidecar_open` and `sidecar_state` say which shape is in front of the
153
+ both `browser_open` and `browser_state` say which shape is in front of the
154
154
  person.
155
155
 
156
156
  The second was silence on a check page. A site asking for proof of a person is
@@ -181,7 +181,7 @@ The tool used to treat "the control that says Email" as "the field called
181
181
  Email". Real forms separate the two, so the writing step now resolves the words
182
182
  to the element that takes text, and says which one it used.
183
183
 
184
- Next in this direction: the same resolution for `sidecar_type` when the caller
184
+ Next in this direction: the same resolution for `browser_type` when the caller
185
185
  names a field by its words instead of a ref, and for date/time pickers where the
186
186
  visible box is a div and the real input is hidden beside it.
187
187
 
@@ -189,15 +189,15 @@ visible box is a div and the real input is hidden beside it.
189
189
 
190
190
  Typing by name is in. The same resolution is what other steps should be able to
191
191
  use when the caller knows the destination but not the number — the next place
192
- worth doing this is `sidecar_select` (choose an option by the field it belongs
192
+ worth doing this is `browser_select` (choose an option by the field it belongs
193
193
  to), and after that the date/time case where the visible box is a div and the
194
194
  real input sits hidden beside it.
195
195
 
196
196
  ## Phase 12 — the same words, the same place, 0.21.0
197
197
 
198
- `sidecar_select` joined `sidecar_type` and `sidecar_do` on the shared resolver.
198
+ `browser_select` joined `browser_type` and `browser_do` on the shared resolver.
199
199
  Left in this direction: the date/time case where the box the user sees is a div
200
- and the real input is hidden beside it, and `sidecar_click` on a control whose
200
+ and the real input is hidden beside it, and `browser_click` on a control whose
201
201
  words are split across sibling nodes (`<span>Sign</span><span>in</span>`).
202
202
 
203
203
  ## Phase 13 — the page's spelling is not the caller's, 0.22.0
@@ -216,7 +216,7 @@ name, so the fallback has to be a picture plus a click, not a name lookup.
216
216
 
217
217
  ## Phase 16 — a file, not a path, 0.25.0
218
218
 
219
- `sidecar_grab` returns a path. Next: say one line about the picture (size, and
219
+ `browser_grab` returns a path. Next: say one line about the picture (size, and
220
220
  whether it is mostly one colour) so a model that cannot open files still learns
221
221
  "the chart is blank" without a full read.
222
222
 
@@ -248,7 +248,7 @@ at -3000,-3000, and the column shows the only picture.
248
248
 
249
249
  ## Phase 21 — a review round that closes the loop, 0.30.0
250
250
 
251
- `sidecar_ask` sends and returns an answer. Next: a round trip that carries the
251
+ `browser_ask` sends and returns an answer. Next: a round trip that carries the
252
252
  repository address there and the review back, and records in the round log which
253
253
  commit the review was about, so a later round can tell whether the advice was
254
254
  already taken.
@@ -298,6 +298,12 @@ The phases above were written one at a time as the work happened; the run from
298
298
 
299
299
  ## 页游这一档(2026-10-28 起)
300
300
 
301
+ - 操作方式已分叉(0.34.29):`browser_mode` 每个聊天记一种,`human` / `dom` 单次可覆盖。页游默认 human,登录/表单默认 dom 更省事。
302
+ - Flash 判死:headless Chrome 里 Ruffle 交得上 swf 但起不了画面(26 秒,canvas 0、metadata 0),不承诺。真页游只能走站方 H5 / 微端 / 老 360 浏览器,页面上线前先用 `scripts/probe-flash.mjs` 问一句它要不要 Flash。
303
+ - 开玩前先体检:`browser_prepare` 一次调用给出输入面、帧率、按键是否真的到得了框架、以及 Flash 结论(0.34.30)。4399《怪物小汽车》实测页面 58 fps、框架 59 fps —— 不少「Flash 小游戏」其实已经是 H5,先体检再下结论;`npm run ready <url>` 可复现。
304
+ - 读 SWF 标签流(`npm run swf`):尺寸、帧率、AVM 版本、资源构成,FWS/CWS 都读,ZWS 明确报不支持(0.34.32)。
305
+ - 空白画面的两种原因分开报(`viewers` / `listening chats` / `asks.log`),并在「浏览器在跑、没人画」时自己向所属会话补发一次开页签请求(0.34.31)。
306
+
301
307
  输入这一层已经到位:按住键、组合键、按住鼠标,一项 300 ms 的按住整次调用 305 ms,事件由页面自己证实。实时画面沿用同一条 CDP 帧路(60 fps、镜像延迟中位数 8 ms)。
302
308
 
303
309
  还差的一层是「看」:4399 / 7k7k / 奥拉星这类页游把画面画在 canvas 上,文本树里没有可读的东西,判断走位、Boss 血条、按钮位置都要靠像素。本会话的模型没有视觉能力,所以这里的正确做法不是假装能玩,而是把可判读的信号做出来:canvas 局部像素采样、颜色/区域变化率、画面是否在动(`blind` 行已经在报 canvas 数量)。有视觉能力的模型接着用同一套帧路就能玩。
@@ -311,7 +317,7 @@ Ranked by what a real task still loses:
311
317
  HTTP and by pid, not by a person clicking. A browser-level test that opens the
312
318
  column, kills the driver, and clicks restore is the missing evidence.
313
319
  2. **One-click "open here" when this chat has no seat.** `state.chats` can lack this
314
- chat, and the column then only tells the model to call `sidecar_open`. The button
320
+ chat, and the column then only tells the model to call `browser_open`. The button
315
321
  belongs in the column.
316
322
  3. **Competitor behaviours still unmatched.** Playwright MCP's accessibility refs are
317
323
  ours, and the blind line covers the vision-less read; Stagehand v4's
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,38 @@ 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
+ /* The shape belongs to the page this chat is actually looking at: a
566
+ chat driving a tab of its own must not resize the browser's base
567
+ page and keep a letterboxed picture. */
568
+ const page = sidecar.pageFor(actor) ?? sidecar.page
569
+ if (page === null || page === undefined) {
570
+ json(409, { ok: false, text: 'the browser is not open' }) // ui-copy-ignore
571
+ return
572
+ }
573
+ json(200, { ok: true, view: await page.setViewport(body.w, body.h) })
574
+ } catch (error) {
575
+ json(500, { ok: false, text: String(error?.message ?? error) })
576
+ }
577
+ return
578
+ }
579
+
466
580
  if (path === '/sidecar/input' && req.method === 'POST') {
467
581
  let body
468
582
  try {
@@ -495,21 +609,26 @@ export function apply(ctx) {
495
609
  } else if (kind === 'wheel') await page.wheel({ x, y, dx: Number(body.dx ?? 0), dy: Number(body.dy ?? 0) })
496
610
  else if (kind === 'text') await page.insertText(String(body.text ?? ''))
497
611
  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 })
612
+ const key = keyEvent(body)
613
+ if (key.down !== true) await page.key('keyUp', { ...key.spec, text: undefined })
614
+ else if (key.repeat === true) {
615
+ /* Typing repeat: the page draws a held key as a fresh keydown. */
616
+ await page.key('keyDown', { ...key.spec, autoRepeat: true })
617
+ } else if (key.command === null) await page.key('keyDown', key.spec)
618
+ else await page.key('keyDown', { ...key.spec, text: undefined, commands: [key.command] })
509
619
  }
510
620
  /* Hovering and moving are looking, not acting: only real input counts as
511
621
  the person taking the page. */
512
- if (kind !== 'hover' && kind !== 'move') sidecar.humanUse({ kind, x, y })
622
+ if (kind !== 'hover' && kind !== 'move') {
623
+ sidecar.humanUse({ kind, x, y })
624
+ /* The arrow goes now, not on the next repaint: a still page may not
625
+ paint again for a second and a half. */
626
+ try {
627
+ page.announceHold()
628
+ } catch {
629
+ /* the stream is gone; the next frame carries the same answer */
630
+ }
631
+ }
513
632
  json(200, cursor === undefined ? { ok: true } : { ok: true, cursor })
514
633
  } catch (error) {
515
634
  json(500, { ok: false, text: String(error?.message ?? error) })
@@ -545,6 +664,44 @@ export function apply(ctx) {
545
664
  return
546
665
  }
547
666
 
667
+ if (path === '/sidecar/tabs' && req.method === 'GET') {
668
+ /* This chat's windows only: another chat's pages never enter this list. */
669
+ const who = acting({ session: url.searchParams.get('session') })
670
+ if (who === '') return
671
+ try {
672
+ const tabs = await sidecar.windowsFor(who)
673
+ json(200, { ok: true, tabs, ...(url.searchParams.get('session') === '' ? { session: who } : {}) })
674
+ } catch (error) {
675
+ json(500, { ok: false, text: String(error?.message ?? error) })
676
+ }
677
+ return
678
+ }
679
+
680
+ if (path === '/sidecar/tabs' && req.method === 'POST') {
681
+ let body
682
+ try {
683
+ body = JSON.parse(await readBody(req))
684
+ } catch {
685
+ json(400, { ok: false, text: 'bad frame' })
686
+ return
687
+ }
688
+ const actor = acting(body)
689
+ if (actor === '') return
690
+ try {
691
+ const at = Number(body.index ?? -1)
692
+ if (body.op === 'close') {
693
+ await sidecar.closeTab(at, actor)
694
+ } else {
695
+ await sidecar.useTab(at, actor)
696
+ }
697
+ const tabs = await sidecar.windowsFor(actor)
698
+ json(200, { ok: true, tabs })
699
+ } catch (error) {
700
+ json(500, { ok: false, text: String(error?.message ?? error) })
701
+ }
702
+ return
703
+ }
704
+
548
705
  if (path === '/sidecar/forget' && req.method === 'POST') {
549
706
  /* Closing the tab is an answer: coming back should not reopen it. Only the
550
707
  chat that holds the mirror may withdraw its own ask. */
@@ -668,11 +825,16 @@ export function apply(ctx) {
668
825
  name: 'browser-in-this-chat',
669
826
  order: 858,
670
827
  text: [
671
- '## 网页任务:用 sidecar 浏览器做,不问直接开始',
828
+ '## 网页任务:人明确点名才开右栏浏览器',
829
+ '右栏「后台浏览器」是一台独立的后台 Chrome,模型能点、能读、能执行脚本;画面只出现在本会话右栏,不占别的会话。它只做「必须动手」的活,默认不开。',
672
830
  '',
673
- '人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成 …」时,直接用 `sidecar_*` 工具;不要问要不要打开,也不要另起一个外部 Chrome。它的画面会出现在本会话右栏,做完保留,不主动关。',
674
- '先接着用已有的页:`sidecar_view`(拿编号控件与可见文字)→ `sidecar_click` / `sidecar_type` → 再 `sidecar_view` 直到出现要的结果;页面要等就重复读,不 sleep。`sidecar_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
675
- '要保存页面样子用 `sidecar_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
831
+ '只有人明确说要看网页/用浏览器时才开始:「用网页端 / 在浏览器里 / 打开某个网站 / 上 ChatGPT 生成 / 帮我在这个页面点…」。人只给了链接、没说要用浏览器时,先用 `web_fetch` 或 envoy 读;读不到(登录墙、空壳、要交互)就说明原因再问一句,不要自己开。',
832
+ '不要用它:读公开文章、单个链接正文、文档、搜索结果摘要、GitHub 仓库或 PR 页面——这些能用 `web_fetch` / envoy 读就不用浏览器;一打开就是一个后台 Chrome 进程加一个页签,会话多了很占内存。',
833
+ '也不要用它做连通性、可达性、服务是否起来的检查:那是一个 HTTP 请求的事,`web_fetch` 或 `curl` 就够,开浏览器只会多一个进程和一栏画面。只有「这一步必须在真实浏览器里点/填/登录」才轮到它。',
834
+ '人点名之后不问、不另起外部 Chrome;画面留在本会话右栏,做完保留,不主动关。',
835
+ '先接着用已有的页:`browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等就重复读,不 sleep。`browser_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
836
+ '页面很长、只想确认某句话/按钮在哪时用 `browser_find`,别整页读。要读长文(文章、文档、issue、论坛帖)用 `browser_read`:它回 Markdown,只花几十分之一的 token,链接可点;要动手点、填表才用 `browser_view` 拿编号。',
837
+ '要保存页面样子用 `browser_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
676
838
  ].join('\n'),
677
839
  }), 'dsh-inner-browser-use: system prompt section')
678
840
  }
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.96",
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": {