dsh-inner-browser-use 0.34.96 → 0.34.98

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 +95 -12
  2. package/index.js +15 -8
  3. package/package.json +1 -1
  4. package/sidecar.js +53 -0
package/client.js CHANGED
@@ -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)
@@ -598,7 +672,16 @@ window.__ModuleLoader__.load({
598
672
  })
599
673
  .then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
600
674
  .then((said) => {
601
- if (!live || said?.ok === true || tries >= 5) return
675
+ if (!live) return
676
+ /* The browser answers with the shape it actually took: drawing
677
+ the picture at that size fills the pane even when the page
678
+ refused the exact one asked for. */
679
+ const took = said?.view ?? null
680
+ if (said?.ok === true && took !== null && Number(took.width) > 0 && Number(took.height) > 0) {
681
+ setView({ w: Math.round(Number(took.width)), h: Math.round(Number(took.height)) })
682
+ return
683
+ }
684
+ if (said?.ok === true || tries >= 5) return
602
685
  tries += 1
603
686
  timer = setTimeout(send, 400)
604
687
  })
package/index.js CHANGED
@@ -131,15 +131,16 @@ export class Bridge {
131
131
  * Remember that a chat wants the mirror, and tell it now if it is listening.
132
132
  * @param {string} sessionId - chat that asked.
133
133
  * @param {number} [at] - when it asked; older asks are not replayed.
134
+ * @param {string} [reason] - 'ask' for a real request, 'keepalive' for a nudge.
134
135
  * @returns {boolean} whether a live tab took the frame.
135
136
  */
136
- want(sessionId, at = Date.now()) {
137
+ want(sessionId, at = Date.now(), reason = 'ask') {
137
138
  const id = this.canon(sessionId)
138
139
  if (id === '') return false
139
140
  this.claim(id)
140
141
  this.wanted = { session: id, at }
141
142
  if (this.persist === true) writeMirror({ owner: this.owner, session: id, at })
142
- return this.push(id, { type: 'sidecar-wanted', session: id, at })
143
+ return this.push(id, { type: 'sidecar-wanted', session: id, at, reason })
143
144
  }
144
145
 
145
146
  /**
@@ -155,7 +156,7 @@ export class Bridge {
155
156
  ask older than two minutes is spent and is not replayed. */
156
157
  const ask = this.wanted
157
158
  if (ask.session === sessionId && Date.now() - ask.at <= 120_000) {
158
- this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at: ask.at })
159
+ this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at: ask.at, reason: 'ask' })
159
160
  }
160
161
  }
161
162
 
@@ -187,14 +188,20 @@ export class Bridge {
187
188
  }
188
189
 
189
190
  /**
190
- * Forget a mirror nobody is waiting for any more.
191
+ * Give the mirror up: the person closed the column, so the chat stops owning
192
+ * it and no keepalive may ask for it again. Ownership is what the column
193
+ * reads on reload, so it has to be dropped on disk as well as in memory.
191
194
  * @param {string} sessionId - chat that closed its tab.
192
- * @returns {boolean} whether the ask was the remembered one.
195
+ * @returns {boolean} whether this chat held the mirror or the ask.
193
196
  */
194
197
  forget(sessionId) {
195
- if (sessionId === '' || this.wanted.session !== sessionId) return false
198
+ const id = this.canon(sessionId)
199
+ if (id === '') return false
200
+ const held = this.owner === id || this.wanted.session === id
196
201
  this.wanted = { session: '', at: 0 }
197
- return true
202
+ if (this.owner === id) this.owner = ''
203
+ if (this.persist === true) writeMirror({ owner: this.owner, session: '', at: 0 })
204
+ return held
198
205
  }
199
206
 
200
207
  /** Close every stream. */
@@ -328,7 +335,7 @@ export function apply(ctx) {
328
335
  const due = nudgeDue({ askedAgo: Date.now() - (lastAsk.get(owner) ?? 0), sinceLast: Date.now() - last }) // ui-copy-ignore
329
336
  if (due !== true) return
330
337
  last = Date.now()
331
- bridge.want(owner)
338
+ bridge.want(owner, Date.now(), 'keepalive')
332
339
  appendFileSync(join(STATE_DIR, 'asks.log'), `${new Date().toISOString()} asked ${owner.slice(-8)} to draw the mirror again\n`)
333
340
  } catch {}
334
341
  }, 4000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.34.96",
3
+ "version": "0.34.98",
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
@@ -1962,6 +1962,10 @@ export class Sidecar {
1962
1962
  /* One tab per chat: a second chat opening an address gets a tab of its
1963
1963
  own, so neither can navigate the other's page away. */
1964
1964
  this.pages = new Map()
1965
+ /* Every tab a chat has driven, so its column can offer its own windows
1966
+ back after it switches away: a tab the chat left is still its own, and a
1967
+ tab nobody claimed belongs to no column at all. */
1968
+ this.claims = new Map()
1965
1969
  /* Tabs left behind by an earlier plugin load are closed once, on the first
1966
1970
  call that finds this browser: a profile used for weeks otherwise keeps a
1967
1971
  renderer per chat that ever opened it. */
@@ -2048,6 +2052,41 @@ export class Sidecar {
2048
2052
  * @param {string} session - chat id, empty for the default tab.
2049
2053
  * @returns {SidecarPage | null} the tab, if the browser is up.
2050
2054
  */
2055
+ /**
2056
+ * Remember that a tab belongs to a chat: switching away from it keeps it in
2057
+ * that chat's window list, and no other chat can see it.
2058
+ * @param {string} session - chat that drove the tab. // ui-copy-ignore
2059
+ * @param {string} targetId - the browser target.
2060
+ */
2061
+ claim(session, targetId) {
2062
+ const who = String(session ?? '')
2063
+ const target = String(targetId ?? '')
2064
+ if (who === '' || target === '') return
2065
+ let set = this.claims.get(who)
2066
+ if (set === undefined) {
2067
+ set = new Set()
2068
+ this.claims.set(who, set)
2069
+ }
2070
+ /* A chat cannot hold an unbounded list: the oldest tab it left falls off. */
2071
+ if (set.size >= 12 && set.has(target) !== true) {
2072
+ const first = set.values().next()
2073
+ if (first.done !== true) set.delete(first.value)
2074
+ }
2075
+ set.add(target)
2076
+ }
2077
+
2078
+ /**
2079
+ * Forget a tab a chat no longer has.
2080
+ * @param {string} session - chat that owned it. // ui-copy-ignore
2081
+ * @param {string} targetId - the browser target.
2082
+ */
2083
+ unclaim(session, targetId) {
2084
+ const set = this.claims.get(String(session ?? ''))
2085
+ if (set === undefined) return
2086
+ set.delete(String(targetId ?? ''))
2087
+ if (set.size === 0) this.claims.delete(String(session ?? ''))
2088
+ }
2089
+
2051
2090
  pageFor(session) {
2052
2091
  const id = String(session ?? '')
2053
2092
  if (id === '') return this.page
@@ -2265,6 +2304,7 @@ export class Sidecar {
2265
2304
  await cdp.ready
2266
2305
  const page = await this.bind(await new SidecarPage(cdp, this.profile, port, () => this.holding()).start()) // ui-copy-ignore
2267
2306
  this.pages.set(id, page)
2307
+ this.claim(id, page.targetId)
2268
2308
  this.touch()
2269
2309
  this.rememberTab(id, address)
2270
2310
  return page
@@ -2614,10 +2654,21 @@ export class Sidecar {
2614
2654
  const list = await this.tabs(session)
2615
2655
  const page = this.pageFor(session) ?? this.page
2616
2656
  const foreign = new Set(typeof page?.siblings === 'function' ? page.siblings().map(String) : [])
2657
+ const who = String(session ?? '')
2658
+ const claimed = this.claims.get(who) ?? new Set()
2659
+ /* Tabs the browser no longer has must not linger in a chat's list. */
2660
+ if (claimed.size > 0) {
2661
+ const live = new Set(list.map((row) => String(row.targetId)))
2662
+ for (const id of [...claimed]) if (live.has(id) !== true) claimed.delete(id)
2663
+ if (claimed.size === 0) this.claims.delete(who)
2664
+ }
2617
2665
  const shown = []
2618
2666
  for (let at = 0; at < list.length; at += 1) {
2619
2667
  const row = list[at]
2620
2668
  if (foreign.has(String(row.targetId))) continue
2669
+ /* A tab nobody in this chat drove is not this chat's window: a page
2670
+ opened behind the browser's back belongs to no column. */
2671
+ if (row.current !== true && claimed.has(String(row.targetId)) !== true) continue
2621
2672
  /* The index is the browser's own row, not this shorter list's: switching
2622
2673
  goes back through `tabs()` unfiltered. */
2623
2674
  shown.push({ title: String(row.title ?? ''), url: String(row.url ?? ''), current: row.current === true, index: at })
@@ -2666,6 +2717,7 @@ export class Sidecar {
2666
2717
  await cdp.ready
2667
2718
  const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port, () => this.holding()).start()) // ui-copy-ignore
2668
2719
  this.pages.set(String(session), page)
2720
+ this.claim(session, tab.targetId)
2669
2721
  mine.cdp.send('Page.stopScreencast').catch(() => {})
2670
2722
  mine.cdp.close()
2671
2723
  await page.cast()
@@ -2701,6 +2753,7 @@ export class Sidecar {
2701
2753
  alone = other < 0
2702
2754
  if (!alone) await this.useTab(other, session)
2703
2755
  }
2756
+ this.unclaim(session, tab.targetId)
2704
2757
  if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
2705
2758
  if (alone && mine !== null) {
2706
2759
  /* The chat still needs somewhere to go: a fresh tab in the same browser. */