dsh-inner-browser-use 0.34.97 → 0.34.99

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.
Files changed (4) hide show
  1. package/client.js +118 -22
  2. package/index.js +50 -12
  3. package/package.json +1 -1
  4. package/sidecar.js +19 -2
package/client.js CHANGED
@@ -73,7 +73,7 @@ window.__ModuleLoader__.load({
73
73
  '.dshib-side-win[data-on="1"]{background:var(--dsw-alias-bg-subtle);color:var(--dsw-alias-label-primary)}',
74
74
  '.dshib-side-win > span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
75
75
  '.dshib-side-stage{flex:1 1 auto;position:relative;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--dsw-alias-bg-base);outline:none;cursor:default}',
76
- '.dshib-side-frame{display:block;user-select:none;-webkit-user-drag:none;cursor:default}',
76
+ '.dshib-side-frame{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none;cursor:default}',
77
77
  '.dshib-side-ime{position:absolute;width:1px;height:1px;padding:0;margin:0;border:0;resize:none;overflow:hidden;opacity:0;pointer-events:none}',
78
78
  /* The black arrow is the agent's pointer, not the person's cursor: both are
79
79
  on screen at once, and moving the mouse never takes the arrow over. */
@@ -119,12 +119,51 @@ window.__ModuleLoader__.load({
119
119
  return ''
120
120
  }
121
121
  }
122
- const closeHere = (sessionId, byPerson = false) => {
123
- /* A tab the person closed is not a tab they want back on return. */
122
+ /* The chat that asked for the pane, and the chats whose pane the
123
+ person closed. A strip close and a layout restore do not both
124
+ report themselves here, so the mark is kept on this side too: no
125
+ automatic ask may redraw a column the person shut. */
126
+ let openWho = ''
127
+ let openAt = 0
128
+ let paneSeen = false
129
+ let missing = 0
130
+ const closedAt = new Map()
131
+ const hasPane = (sessionId) => {
124
132
  try {
125
- void fetch(`${ROUTE}/sidecar/forget?session=${encodeURIComponent(sessionId)}`, { method: 'POST' }) // ui-copy-ignore
133
+ const rows = surface?.tabsIn?.(sessionId) ?? []
134
+ return rows.some((row) => {
135
+ const kind = String(row?.kind ?? row?.record?.kind ?? '')
136
+ const id = String(row?.id ?? row?.record?.id ?? '')
137
+ return kind === SIDE_KIND || id === SIDE_ID
138
+ })
126
139
  } catch {
127
- /* a page that cannot hear it keeps its note */
140
+ return false
141
+ }
142
+ }
143
+ const closeHere = (sessionId, byPerson = false) => {
144
+ if (sessionId === '') return
145
+ closedAt.set(sessionId, Date.now())
146
+ if (openWho === sessionId) {
147
+ openWho = ''
148
+ openAt = 0
149
+ paneSeen = false
150
+ missing = 0
151
+ }
152
+ /* A note is sent only when the person is quitting the mirror, not when a
153
+ chat switch closes this column: the switch's own ask claims the
154
+ mirror a moment later, and a late note would take it away from the
155
+ chat that just asked. */
156
+ if (byPerson === true) {
157
+ /* A close usually unmounts the pane in the same tick, which cancels
158
+ an ordinary request; a beacon is delivered after the page is gone,
159
+ so the note always arrives. */
160
+ const url = `${ROUTE}/sidecar/forget?session=${encodeURIComponent(sessionId)}` // ui-copy-ignore
161
+ try {
162
+ const sent = typeof navigator?.sendBeacon === 'function' && navigator.sendBeacon(url) === true
163
+ if (sent !== true) void fetch(url, { method: 'POST' })
164
+ } catch {
165
+ /* a page that cannot hear it keeps its note */
166
+ }
128
167
  }
129
168
  let records = []
130
169
  try {
@@ -149,10 +188,23 @@ window.__ModuleLoader__.load({
149
188
  }
150
189
  }
151
190
  }
152
- const ask = (sessionId) => {
191
+ const ask = (sessionId, reason = 'ask', at = 0) => {
153
192
  if (sessionId === '' || mountedOf() !== sessionId) return
193
+ const shut = closedAt.get(sessionId)
194
+ /* A slow beat that only wants the frames back must not put a closed
195
+ column on screen again: it may refresh a pane that is drawn and
196
+ nothing else. */
197
+ if (reason === 'keepalive' && (shut !== undefined || !hasPane(sessionId))) return
198
+ /* A real ask is only newer than the close that preceded it: a
199
+ replayed one from before the person shut the pane stays spent. */
200
+ if (shut !== undefined && (reason !== 'ask' || (at !== 0 && at <= shut))) return
201
+ if (shut !== undefined) closedAt.delete(sessionId)
154
202
  if (ownerSession !== '' && ownerSession !== sessionId) closeHere(ownerSession)
155
203
  ownerSession = sessionId
204
+ openWho = sessionId
205
+ missing = 0
206
+ paneSeen = hasPane(sessionId)
207
+ if (paneSeen !== true) openAt = Date.now()
156
208
  try {
157
209
  if (typeof surface?.openTabIn === 'function') surface.openTabIn(sessionId, SIDE_KIND, {})
158
210
  else if (typeof surface?.openTab === 'function') surface.openTab(SIDE_KIND)
@@ -160,6 +212,25 @@ window.__ModuleLoader__.load({
160
212
  /* a column that refuses keeps its own tabs */
161
213
  }
162
214
  }
215
+ /* The pane this chat asked for can be taken down by paths that never
216
+ call onClose. One that was drawn and is gone was closed by the
217
+ person, and that is the answer this watcher waits for. */
218
+ const watchPane = () => {
219
+ if (openWho === '') return
220
+ if (mountedOf() !== openWho) return
221
+ if (hasPane(openWho)) {
222
+ paneSeen = true
223
+ missing = 0
224
+ return
225
+ }
226
+ /* A pane the person never saw draw is still being built; only one
227
+ that was on screen and then vanished is a close. */
228
+ if (paneSeen !== true) return
229
+ if (Date.now() - openAt < 2500) return
230
+ missing += 1
231
+ if (missing < 2) return
232
+ closeHere(openWho, true)
233
+ }
163
234
  /* The same rule on this side: a second mount of the same tab is a
164
235
  no-op, not a thrown error that takes the pane down. */
165
236
  const safely = (register) => {
@@ -177,7 +248,7 @@ window.__ModuleLoader__.load({
177
248
  priority: 'extension',
178
249
  title: () => L('side.label'),
179
250
  /* The tab strip's close control is not the pane's own button: a
180
- tab closed there never told the host, the ask stayed alive, and
251
+ tab closed there never sent the note, the ask stayed alive, and
181
252
  the keepalive put the mirror back a few seconds later — a tab
182
253
  the person closed that would not stay closed. Every close path
183
254
  ends here. */
@@ -186,7 +257,7 @@ window.__ModuleLoader__.load({
186
257
  disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
187
258
  name: SLOT, // ui-copy-ignore
188
259
  key: SIDE_ID, // ui-copy-ignore
189
- inject: (sessionId) => ({ sessionId, dropMirror: () => closeHere(sessionId) }),
260
+ inject: (sessionId) => ({ sessionId, dropMirror: () => closeHere(sessionId, true) }),
190
261
  }, SidecarBody)))
191
262
  disposers.push(scope.slots.inject(TITLE_SLOT, () => scope.slots.register({
192
263
  name: TITLE_SLOT, // ui-copy-ignore
@@ -195,7 +266,7 @@ window.__ModuleLoader__.load({
195
266
  }, () => h('span', { className: 'dshib-title' }, L('side.label')))))
196
267
  /* The mirror comes only when this chat asked for it: the ask is pushed
197
268
  to this chat's page, and nothing opens by itself. // ui-copy-ignore */
198
- sidecarOpener = (sessionId) => ask(sessionId)
269
+ sidecarOpener = (sessionId, reason = 'ask', at = 0) => ask(sessionId, reason, at)
199
270
  /* The ask arrives on this chat's own downlink, so a frame for another
200
271
  chat never reaches this column. The stream follows the chat on
201
272
  screen: a switch closes the old one and opens the new one. */
@@ -231,7 +302,7 @@ window.__ModuleLoader__.load({
231
302
  }
232
303
  if (frame?.type !== 'sidecar-wanted') return
233
304
  const asked = String(frame.session ?? '')
234
- if (asked !== '') sidecarOpener?.(asked)
305
+ if (asked !== '') sidecarOpener?.(asked, String(frame.reason ?? 'ask'), Number(frame.at ?? 0))
235
306
  })
236
307
  askSource = source
237
308
  askWho = who
@@ -276,7 +347,7 @@ window.__ModuleLoader__.load({
276
347
  again()
277
348
  return
278
349
  }
279
- ask(who)
350
+ ask(who, 'restore')
280
351
  })
281
352
  .catch(again)
282
353
  } catch {
@@ -285,7 +356,10 @@ window.__ModuleLoader__.load({
285
356
  }
286
357
  restore()
287
358
  const offAsk = watchAsk()
288
- const pollAsk = setInterval(syncAsk, 1000)
359
+ const pollAsk = setInterval(() => {
360
+ syncAsk()
361
+ watchPane()
362
+ }, 1000)
289
363
  const stopAsk = () => {
290
364
  clearInterval(pollAsk)
291
365
  for (const timer of restoreTimers) clearTimeout(timer)
@@ -571,9 +645,27 @@ window.__ModuleLoader__.load({
571
645
  .catch(() => {})
572
646
  }
573
647
 
574
- const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
575
- const padX = (size.w - view.w * scale) / 2
576
- const padY = (size.h - view.h * scale) / 2
648
+ /* Where the page actually got drawn inside the column: the frame fills
649
+ the stage and the picture sits inside it, so the answer comes from the
650
+ element that is on screen rather than from the last size remembered.
651
+ A stale measure can then never shrink the picture to a thumbnail. */
652
+ const frameEl = React.useRef(null)
653
+ const picture = () => {
654
+ const at = frameEl.current
655
+ const areaEl = stage.current
656
+ if (at === null || at === undefined || areaEl === null || areaEl === undefined) return null
657
+ if (!(view.w > 0) || !(view.h > 0)) return null
658
+ const a = at.getBoundingClientRect()
659
+ const b = areaEl.getBoundingClientRect()
660
+ if (!(a.width > 1) || !(a.height > 1)) return null
661
+ const scale = Math.min(a.width / view.w, a.height / view.h)
662
+ if (!(scale > 0)) return null
663
+ return {
664
+ scale,
665
+ x: a.left - b.left + (a.width - view.w * scale) / 2,
666
+ y: a.top - b.top + (a.height - view.h * scale) / 2,
667
+ }
668
+ }
577
669
 
578
670
  /* The column tells the page how big it is, a beat after the resize stops:
579
671
  a page that relayouts on every pointer step would never settle. */
@@ -655,10 +747,12 @@ window.__ModuleLoader__.load({
655
747
  }
656
748
  const toView = (event) => {
657
749
  const el = stage.current
658
- if (el === null || scale <= 0) return null
750
+ if (el === null) return null
751
+ const spot = picture()
752
+ if (spot === null) return null
659
753
  const box = el.getBoundingClientRect()
660
- const x = (event.clientX - box.left - padX) / scale
661
- const y = (event.clientY - box.top - padY) / scale
754
+ const x = (event.clientX - box.left - spot.x) / spot.scale
755
+ const y = (event.clientY - box.top - spot.y) / spot.scale
662
756
  if (x < 0 || y < 0 || x > view.w || y > view.h) return null
663
757
  return { x: Math.round(x), y: Math.round(y) }
664
758
  }
@@ -754,6 +848,7 @@ window.__ModuleLoader__.load({
754
848
  h('div', { className: 'dshib-side-note' }, L('side.foreign')))
755
849
  }
756
850
  const pointer = person === true || hand === true ? null : ptr
851
+ const spot = pointer === null ? null : (picture() ?? { scale: 1, x: 0, y: 0 })
757
852
  const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
758
853
  return h('div', { className: 'dshib-side' },
759
854
  h('div', { className: 'dshib-side-bar' },
@@ -820,7 +915,7 @@ window.__ModuleLoader__.load({
820
915
  tabIndex: 0,
821
916
  spellCheck: false,
822
917
  'aria-label': L('side.type'),
823
- style: pointer === null ? undefined : { left: `${padX + pointer.x * scale}px`, top: `${padY + pointer.y * scale}px` },
918
+ style: pointer === null || spot === null ? undefined : { left: `${spot.x + pointer.x * spot.scale}px`, top: `${spot.y + pointer.y * spot.scale}px` },
824
919
  onCompositionStart: () => {
825
920
  composing.current = true
826
921
  },
@@ -839,9 +934,10 @@ window.__ModuleLoader__.load({
839
934
  }),
840
935
  frame === null ? null : h('img', {
841
936
  className: 'dshib-side-frame',
937
+ ref: frameEl,
842
938
  src: `data:image/jpeg;base64,${frame.jpeg}`,
843
- width: Math.max(1, Math.round(view.w * scale)),
844
- height: Math.max(1, Math.round(view.h * scale)),
939
+ width: Math.max(1, Math.round(view.w)),
940
+ height: Math.max(1, Math.round(view.h)),
845
941
  alt: '', // ui-copy-ignore
846
942
  draggable: false,
847
943
  }),
@@ -849,7 +945,7 @@ window.__ModuleLoader__.load({
849
945
  className: 'dshib-side-ptr',
850
946
  src: SIDE_POINTER, // ui-copy-ignore
851
947
  alt: '', // ui-copy-ignore
852
- style: { left: `${padX + pointer.x * scale}px`, top: `${padY + pointer.y * scale}px` },
948
+ style: spot === null ? undefined : { left: `${spot.x + pointer.x * spot.scale}px`, top: `${spot.y + pointer.y * spot.scale}px` },
853
949
  })))
854
950
  }
855
951
  },
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 { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
23
+ import { appendFileSync, mkdirSync, readFileSync, statSync, 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'
@@ -50,6 +50,27 @@ function writeMirror(value) {
50
50
  }
51
51
  }
52
52
 
53
+ /** The note stops growing: past this size the oldest half goes, so a long-lived run never fills the disk. */
54
+ const NOTES_MAX = 256 * 1024
55
+ const NOTES_KEEP = 128 * 1024
56
+
57
+ /**
58
+ * Add one line to the mirror's own note, keeping the file bounded.
59
+ * @param {string} line - what happened, one line.
60
+ */
61
+ export function note(line) {
62
+ const file = join(STATE_DIR, 'asks.log')
63
+ try {
64
+ mkdirSync(STATE_DIR, { recursive: true })
65
+ appendFileSync(file, `${new Date().toISOString()} ${line}\n`)
66
+ if (statSync(file).size <= NOTES_MAX) return
67
+ const whole = readFileSync(file, 'utf8')
68
+ writeFileSync(file, whole.slice(whole.length - NOTES_KEEP).replace(/^[^\n]*\n/, ''))
69
+ } catch {
70
+ /* a note that cannot be written must not take the mirror down */
71
+ }
72
+ }
73
+
53
74
  /** Route prefix owned by this plugin. Never under `/api`, which is the Host's own channel. */ // ui-copy-ignore
54
75
  export const ROUTE = '/dsh-inner-browser-use'
55
76
 
@@ -131,15 +152,16 @@ export class Bridge {
131
152
  * Remember that a chat wants the mirror, and tell it now if it is listening.
132
153
  * @param {string} sessionId - chat that asked.
133
154
  * @param {number} [at] - when it asked; older asks are not replayed.
155
+ * @param {string} [reason] - 'ask' for a real request, 'keepalive' for a nudge.
134
156
  * @returns {boolean} whether a live tab took the frame.
135
157
  */
136
- want(sessionId, at = Date.now()) {
158
+ want(sessionId, at = Date.now(), reason = 'ask') {
137
159
  const id = this.canon(sessionId)
138
160
  if (id === '') return false
139
161
  this.claim(id)
140
162
  this.wanted = { session: id, at }
141
163
  if (this.persist === true) writeMirror({ owner: this.owner, session: id, at })
142
- return this.push(id, { type: 'sidecar-wanted', session: id, at })
164
+ return this.push(id, { type: 'sidecar-wanted', session: id, at, reason })
143
165
  }
144
166
 
145
167
  /**
@@ -155,7 +177,7 @@ export class Bridge {
155
177
  ask older than two minutes is spent and is not replayed. */
156
178
  const ask = this.wanted
157
179
  if (ask.session === sessionId && Date.now() - ask.at <= 120_000) {
158
- this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at: ask.at })
180
+ this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at: ask.at, reason: 'ask' })
159
181
  }
160
182
  }
161
183
 
@@ -187,14 +209,20 @@ export class Bridge {
187
209
  }
188
210
 
189
211
  /**
190
- * Forget a mirror nobody is waiting for any more.
212
+ * Give the mirror up: the person closed the column, so the chat stops owning
213
+ * it and no keepalive may ask for it again. Ownership is what the column
214
+ * reads on reload, so it has to be dropped on disk as well as in memory.
191
215
  * @param {string} sessionId - chat that closed its tab.
192
- * @returns {boolean} whether the ask was the remembered one.
216
+ * @returns {boolean} whether this chat held the mirror or the ask.
193
217
  */
194
218
  forget(sessionId) {
195
- if (sessionId === '' || this.wanted.session !== sessionId) return false
219
+ const id = this.canon(sessionId)
220
+ if (id === '') return false
221
+ const held = this.owner === id || this.wanted.session === id
196
222
  this.wanted = { session: '', at: 0 }
197
- return true
223
+ if (this.owner === id) this.owner = ''
224
+ if (this.persist === true) writeMirror({ owner: this.owner, session: '', at: 0 })
225
+ return held
198
226
  }
199
227
 
200
228
  /** Close every stream. */
@@ -328,8 +356,8 @@ export function apply(ctx) {
328
356
  const due = nudgeDue({ askedAgo: Date.now() - (lastAsk.get(owner) ?? 0), sinceLast: Date.now() - last }) // ui-copy-ignore
329
357
  if (due !== true) return
330
358
  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`)
359
+ bridge.want(owner, Date.now(), 'keepalive')
360
+ note(`asked ${owner.slice(-8)} to draw the mirror again`)
333
361
  } catch {}
334
362
  }, 4000)
335
363
  timer.unref?.()
@@ -504,7 +532,7 @@ export function apply(ctx) {
504
532
  if (Date.now() - before > 2000) {
505
533
  lastAsk.set(chat, Date.now())
506
534
  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`)
535
+ note(`${chat.slice(-8)} alive=${String(said?.alive === true)} viewers=${String(said?.viewers ?? 0)}`)
508
536
  } catch {}
509
537
  }
510
538
  }
@@ -708,7 +736,17 @@ export function apply(ctx) {
708
736
  const body = await readBody(req).then((raw) => JSON.parse(raw || '{}')).catch(() => ({}))
709
737
  const who = acting(body)
710
738
  if (who === '') return
711
- json(200, { ok: true, forgot: bridge.forget(who) })
739
+ const forgot = bridge.forget(who)
740
+ /* Nobody is watching a picture the person just closed: the browser itself
741
+ is put away a minute later rather than a quarter of an hour, unless an
742
+ agent asks for it again in the meantime. */
743
+ try {
744
+ if (sidecar.watched() === 0) {
745
+ sidecar.touch(REST_MS)
746
+ note('said ' + who.slice(-8) + ' closed the mirror - browser rests in a minute')
747
+ }
748
+ } catch { /* a browser that never started needs no rest timer */ }
749
+ json(200, { ok: true, forgot })
712
750
  return
713
751
  }
714
752
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.34.97",
3
+ "version": "0.34.99",
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
@@ -95,6 +95,12 @@ export function nextQuality(current, base64) {
95
95
 
96
96
  /** Idle lifetime, so a forgotten sidecar does not sit on a login forever. */
97
97
  const IDLE_MS = 15 * 60_000
98
+ /* A pane the person closed is a stronger signal than a pane nobody touched: the
99
+ browser is put away a minute later unless someone asks for it again. A whole
100
+ Chrome with its renderers is a few hundred megabytes, and leaving it up for a
101
+ quarter of an hour after a deliberate close is exactly the cost they did not
102
+ want. */
103
+ const REST_MS = 60_000
98
104
  /* One browser serves every chat, but a chat that is not on screen need not keep
99
105
  a full renderer alive for ever: past this many chats the oldest unwatched tab
100
106
  is closed, and its address brings it back on the next visit. */
@@ -2836,7 +2842,18 @@ export class Sidecar {
2836
2842
  }
2837
2843
  }
2838
2844
 
2839
- touch() {
2845
+ /** @returns {number} how many panes are watching a picture right now. */
2846
+ watched() {
2847
+ let n = this.page?.frames?.size ?? 0
2848
+ for (const page of this.pages?.values() ?? []) n += page.frames?.size ?? 0
2849
+ return n
2850
+ }
2851
+
2852
+ /**
2853
+ * Starts the idle clock again.
2854
+ * @param {number} [ms] - how long a browser nobody watches may stay up.
2855
+ */
2856
+ touch(ms = IDLE_MS) {
2840
2857
  if (this.idle !== null) clearTimeout(this.idle)
2841
2858
  this.idle = setTimeout(() => {
2842
2859
  this.idle = null
@@ -2849,7 +2866,7 @@ export class Sidecar {
2849
2866
  return
2850
2867
  }
2851
2868
  void this.shutdown()
2852
- }, IDLE_MS)
2869
+ }, ms)
2853
2870
  this.idle.unref?.()
2854
2871
  }
2855
2872