dsh-inner-browser-use 0.34.98 → 0.35.0

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 +58 -36
  2. package/index.js +39 -5
  3. package/package.json +1 -1
  4. package/sidecar.js +111 -8
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. */
@@ -355,10 +355,27 @@ window.__ModuleLoader__.load({
355
355
  }
356
356
  }
357
357
  restore()
358
+ /* A pane whose page the browser reclaimed is closed once, so coming
359
+ back to the chat does not bring the empty column back. */
360
+ const dropExpired = () => {
361
+ const who = mountedOf()
362
+ if (who === '' || hasPane(who) !== true) return
363
+ void fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`) // ui-copy-ignore
364
+ .then((said) => (said.ok === true ? said.json() : null))
365
+ .then((state) => {
366
+ if (state?.chat?.expired !== true) return
367
+ if (mountedOf() !== who) return
368
+ closeHere(who, false)
369
+ })
370
+ .catch(() => {})
371
+ }
358
372
  const offAsk = watchAsk()
373
+ let beats = 0
359
374
  const pollAsk = setInterval(() => {
360
375
  syncAsk()
361
376
  watchPane()
377
+ beats += 1
378
+ if (beats % 5 === 1) dropExpired()
362
379
  }, 1000)
363
380
  const stopAsk = () => {
364
381
  clearInterval(pollAsk)
@@ -645,9 +662,27 @@ window.__ModuleLoader__.load({
645
662
  .catch(() => {})
646
663
  }
647
664
 
648
- const scale = size.w > 0 && size.h > 0 ? Math.min(size.w / view.w, size.h / view.h) : 0
649
- const padX = (size.w - view.w * scale) / 2
650
- const padY = (size.h - view.h * scale) / 2
665
+ /* Where the page actually got drawn inside the column: the frame fills
666
+ the stage and the picture sits inside it, so the answer comes from the
667
+ element that is on screen rather than from the last size remembered.
668
+ A stale measure can then never shrink the picture to a thumbnail. */
669
+ const frameEl = React.useRef(null)
670
+ const picture = () => {
671
+ const at = frameEl.current
672
+ const areaEl = stage.current
673
+ if (at === null || at === undefined || areaEl === null || areaEl === undefined) return null
674
+ if (!(view.w > 0) || !(view.h > 0)) return null
675
+ const a = at.getBoundingClientRect()
676
+ const b = areaEl.getBoundingClientRect()
677
+ if (!(a.width > 1) || !(a.height > 1)) return null
678
+ const scale = Math.min(a.width / view.w, a.height / view.h)
679
+ if (!(scale > 0)) return null
680
+ return {
681
+ scale,
682
+ x: a.left - b.left + (a.width - view.w * scale) / 2,
683
+ y: a.top - b.top + (a.height - view.h * scale) / 2,
684
+ }
685
+ }
651
686
 
652
687
  /* The column tells the page how big it is, a beat after the resize stops:
653
688
  a page that relayouts on every pointer step would never settle. */
@@ -656,15 +691,11 @@ window.__ModuleLoader__.load({
656
691
  /* The page already has that shape: a resize that lands between two
657
692
  viewport sizes would only cost a relayout and another frame. */
658
693
  if (Math.abs(Number(view?.w ?? 0) - size.w) < 1 && Math.abs(Number(view?.h ?? 0) - size.h) < 1) return undefined
659
- /* The page is told the column's size a beat after the resize stops, and
660
- again until the browser says it took it: a request that arrives while
661
- the page is still starting is refused, and a pane that gives up then
662
- keeps a letterboxed picture for as long as it stays open. */
663
- let live = true
664
- let tries = 0
665
- let timer = 0
666
- const send = () => {
667
- if (!live) return
694
+ /* The page is told the column's size a beat after the resize stops.
695
+ One ask: the pane draws at whatever shape the frame says it has, so
696
+ a browser that is still starting costs nothing more than the next
697
+ resize doing the same thing again. */
698
+ const timer = setTimeout(() => {
668
699
  fetch(`${ROUTE}/sidecar/viewport`, {
669
700
  method: 'POST',
670
701
  headers: { 'content-type': 'application/json' },
@@ -672,30 +703,17 @@ window.__ModuleLoader__.load({
672
703
  })
673
704
  .then((answer) => (answer.ok === true ? answer.json() : { ok: false }))
674
705
  .then((said) => {
675
- if (!live) return
676
706
  /* The browser answers with the shape it actually took: drawing
677
707
  the picture at that size fills the pane even when the page
678
708
  refused the exact one asked for. */
679
709
  const took = said?.view ?? null
680
710
  if (said?.ok === true && took !== null && Number(took.width) > 0 && Number(took.height) > 0) {
681
711
  setView({ w: Math.round(Number(took.width)), h: Math.round(Number(took.height)) })
682
- return
683
712
  }
684
- if (said?.ok === true || tries >= 5) return
685
- tries += 1
686
- timer = setTimeout(send, 400)
687
713
  })
688
- .catch(() => {
689
- if (!live || tries >= 5) return
690
- tries += 1
691
- timer = setTimeout(send, 600)
692
- })
693
- }
694
- timer = setTimeout(send, 250)
695
- return () => {
696
- live = false
697
- clearTimeout(timer)
698
- }
714
+ .catch(() => {})
715
+ }, 250)
716
+ return () => clearTimeout(timer)
699
717
  }, [mine, size.w, size.h, view?.w, view?.h])
700
718
 
701
719
  const who = String(props.sessionId ?? '')
@@ -729,10 +747,12 @@ window.__ModuleLoader__.load({
729
747
  }
730
748
  const toView = (event) => {
731
749
  const el = stage.current
732
- if (el === null || scale <= 0) return null
750
+ if (el === null) return null
751
+ const spot = picture()
752
+ if (spot === null) return null
733
753
  const box = el.getBoundingClientRect()
734
- const x = (event.clientX - box.left - padX) / scale
735
- 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
736
756
  if (x < 0 || y < 0 || x > view.w || y > view.h) return null
737
757
  return { x: Math.round(x), y: Math.round(y) }
738
758
  }
@@ -828,6 +848,7 @@ window.__ModuleLoader__.load({
828
848
  h('div', { className: 'dshib-side-note' }, L('side.foreign')))
829
849
  }
830
850
  const pointer = person === true || hand === true ? null : ptr
851
+ const spot = pointer === null ? null : (picture() ?? { scale: 1, x: 0, y: 0 })
831
852
  const noteText = phase === 'failed' ? note || L('side.failed') : phase === 'live' ? L('side.empty') : L('side.connecting')
832
853
  return h('div', { className: 'dshib-side' },
833
854
  h('div', { className: 'dshib-side-bar' },
@@ -894,7 +915,7 @@ window.__ModuleLoader__.load({
894
915
  tabIndex: 0,
895
916
  spellCheck: false,
896
917
  'aria-label': L('side.type'),
897
- 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` },
898
919
  onCompositionStart: () => {
899
920
  composing.current = true
900
921
  },
@@ -913,9 +934,10 @@ window.__ModuleLoader__.load({
913
934
  }),
914
935
  frame === null ? null : h('img', {
915
936
  className: 'dshib-side-frame',
937
+ ref: frameEl,
916
938
  src: `data:image/jpeg;base64,${frame.jpeg}`,
917
- width: Math.max(1, Math.round(view.w * scale)),
918
- 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)),
919
941
  alt: '', // ui-copy-ignore
920
942
  draggable: false,
921
943
  }),
@@ -923,7 +945,7 @@ window.__ModuleLoader__.load({
923
945
  className: 'dshib-side-ptr',
924
946
  src: SIDE_POINTER, // ui-copy-ignore
925
947
  alt: '', // ui-copy-ignore
926
- 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` },
927
949
  })))
928
950
  }
929
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
 
@@ -259,6 +280,9 @@ export function cwdOf(execution) {
259
280
 
260
281
  export function needsTab(chatState, chat, owner) {
261
282
  if (String(chat ?? '') === '' || owner !== chat) return false
283
+ /* A tab the browser reclaimed is not a tab that went missing: opening one
284
+ again would bring back the column the person no longer needs. */
285
+ if (chatState?.expired === true) return false
262
286
  return chatState?.alive !== true
263
287
  }
264
288
 
@@ -336,7 +360,7 @@ export function apply(ctx) {
336
360
  if (due !== true) return
337
361
  last = Date.now()
338
362
  bridge.want(owner, Date.now(), 'keepalive')
339
- appendFileSync(join(STATE_DIR, 'asks.log'), `${new Date().toISOString()} asked ${owner.slice(-8)} to draw the mirror again\n`)
363
+ note(`asked ${owner.slice(-8)} to draw the mirror again`)
340
364
  } catch {}
341
365
  }, 4000)
342
366
  timer.unref?.()
@@ -500,7 +524,7 @@ export function apply(ctx) {
500
524
  called open again: the mirror would come back empty in a chat that
501
525
  never closed it. The chat that owned the browser asks for its state, so
502
526
  answering honestly means picking the browser up first. */
503
- if (shouldAdoptOnState(said, chat, bridge.owner)) {
527
+ if (shouldAdoptOnState(said, chat, bridge.owner) && mineChat?.expired !== true) {
504
528
  try {
505
529
  await sidecar.ensure(adoptOptions(chat, sidecar.rememberedTab?.(chat)))
506
530
  said = await sidecar.state()
@@ -511,7 +535,7 @@ export function apply(ctx) {
511
535
  if (Date.now() - before > 2000) {
512
536
  lastAsk.set(chat, Date.now())
513
537
  try {
514
- appendFileSync(join(STATE_DIR, 'asks.log'), `${new Date().toISOString()} ${chat.slice(-8)} alive=${String(said?.alive === true)} viewers=${String(said?.viewers ?? 0)}\n`)
538
+ note(`${chat.slice(-8)} alive=${String(said?.alive === true)} viewers=${String(said?.viewers ?? 0)}`)
515
539
  } catch {}
516
540
  }
517
541
  }
@@ -715,7 +739,17 @@ export function apply(ctx) {
715
739
  const body = await readBody(req).then((raw) => JSON.parse(raw || '{}')).catch(() => ({}))
716
740
  const who = acting(body)
717
741
  if (who === '') return
718
- json(200, { ok: true, forgot: bridge.forget(who) })
742
+ const forgot = bridge.forget(who)
743
+ /* Nobody is watching a picture the person closed: the browser itself is
744
+ put away a minute later rather than a quarter of an hour, unless an
745
+ agent asks for it again in the meantime. */
746
+ try {
747
+ if (sidecar.watched() === 0) {
748
+ sidecar.touch(REST_MS)
749
+ note('said ' + who.slice(-8) + ' closed the mirror - browser rests in a minute')
750
+ }
751
+ } catch { /* a browser that never started needs no rest timer */ }
752
+ json(200, { ok: true, forgot })
719
753
  return
720
754
  }
721
755
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.34.98",
3
+ "version": "0.35.0",
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
@@ -78,7 +78,7 @@ const QUALITY_FLOOR = 44 // ui-copy-ignore
78
78
  const QUALITY_CEILING = 76 // ui-copy-ignore
79
79
 
80
80
  /**
81
- * Decide the next JPEG quality from the weight of the frame just seen. A frame
81
+ * Decide the next JPEG quality from the weight of the frame seen. A frame
82
82
  * near 90 KB of base64 is a heavy page: lowering quality by 8 costs a little
83
83
  * sharpness and saves a lot of transport. A small frame has room to sharpen.
84
84
  * @param {number} current - quality now.
@@ -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. */
@@ -472,6 +478,30 @@ class Cdp {
472
478
  * Input is real CDP input: the page sees the same events a person's trackpad
473
479
  * produces, which is what makes sites that reject scripted clicks work.
474
480
  */
481
+ /**
482
+ * Whether two addresses show the same place: a fragment never changes the page.
483
+ * @param {string} a - one address.
484
+ * @param {string} b - another address.
485
+ * @returns {string} the shared key, or '' when they differ.
486
+ */
487
+ function placeKey(value) {
488
+ const said = String(value ?? '')
489
+ if (said === '') return ''
490
+ try {
491
+ const url = new URL(said)
492
+ url.hash = ''
493
+ const path = url.pathname.replace(/\/+$/, '') || '/'
494
+ return `${url.protocol}//${url.host}${path}${url.search}`
495
+ } catch {
496
+ return said
497
+ }
498
+ }
499
+
500
+ function samePlace(a, b) {
501
+ const key = placeKey(a)
502
+ return key !== '' && key === placeKey(b)
503
+ }
504
+
475
505
  export class SidecarPage {
476
506
  /** @param {Cdp} cdp - the page session. @param {string} profile - profile name. */
477
507
  constructor(cdp, profile, port, hold = () => 0) { // ui-copy-ignore
@@ -768,9 +798,10 @@ export class SidecarPage {
768
798
  .then((response) => (response.ok ? response.json() : []))
769
799
  .catch(() => [])
770
800
  const current = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
771
- return (Array.isArray(list) ? list : [])
801
+ const rows = (Array.isArray(list) ? list : [])
772
802
  .filter((item) => item.type === 'page' && item.webSocketDebuggerUrl !== undefined)
773
803
  .map((item) => ({ targetId: String(item.id), title: String(item.title ?? ''), url: String(item.url ?? ''), webSocketUrl: String(item.webSocketDebuggerUrl), current: String(item.id) === current }))
804
+ return rows
774
805
  }
775
806
 
776
807
  /**
@@ -1294,7 +1325,7 @@ export class SidecarPage {
1294
1325
  /**
1295
1326
  * Whether this picture repeats the one the pane is already showing.
1296
1327
  * @param {string} jpeg - base64 JPEG of the new frame.
1297
- * @returns {boolean} true when sending it would just cost bytes.
1328
+ * @returns {boolean} true when sending it would cost bytes.
1298
1329
  */
1299
1330
  repeats(jpeg, gap = 1000) {
1300
1331
  const prev = this.lastShot
@@ -1973,6 +2004,8 @@ export class Sidecar {
1973
2004
  /* A tab being opened for a chat: a second call for the same chat waits for
1974
2005
  it instead of opening a twin. */
1975
2006
  this.opening = new Map()
2007
+ /* Chats whose tab was reclaimed: the column is told so it can go away. */
2008
+ this.expired = new Map()
1976
2009
  /* A measurement or a test may want a profile of its own; the person's
1977
2010
  browser keeps the default one. */
1978
2011
  this.profile = String(options.profile ?? 'default')
@@ -2151,6 +2184,7 @@ export class Sidecar {
2151
2184
  */
2152
2185
  async tabFor(session, url) {
2153
2186
  const id = String(session ?? '')
2187
+ this.expired?.delete(id)
2154
2188
  const had = this.pageFor(id)
2155
2189
  if (had !== null) {
2156
2190
  if (url !== undefined && url !== '') await had.navigate(url)
@@ -2262,23 +2296,62 @@ export class Sidecar {
2262
2296
  if (page === this.page) continue
2263
2297
  if (now - Number(page.touched ?? 0) < TAB_IDLE_MS) continue
2264
2298
  this.pages.delete(id)
2299
+ /* The column is told the tab is gone, so a chat coming back to the front
2300
+ does not re-expand a pane for a page that already went away. */
2301
+ ;(this.expired ??= new Map()).set(String(id), Date.now())
2265
2302
  await page.stopCast().catch(() => {})
2266
2303
  await fetch(`http://127.0.0.1:${String(page.port)}/json/close/${target}`, { signal: AbortSignal.timeout(800) }).catch(() => {}) // ui-copy-ignore
2267
2304
  this.note(`closed an idle tab — its address is kept for the chat`)
2268
2305
  }
2269
2306
  }
2270
2307
 
2308
+ /**
2309
+ * A page already at an address, so a second open reuses it. A page another
2310
+ * chat is driving is that chat's, and is not offered.
2311
+ * @param {string} url - address wanted.
2312
+ * @param {string} except - chat doing the asking.
2313
+ * @returns {SidecarPage|null} the page, or null.
2314
+ */
2315
+ tabShowing(url, except) {
2316
+ const wanted = String(url ?? '')
2317
+ if (wanted === '' || wanted === 'about:blank') return null
2318
+ const held = new Set()
2319
+ for (const [who, set] of this.claims) {
2320
+ if (String(who) === String(except)) continue
2321
+ for (const target of set) held.add(String(target))
2322
+ }
2323
+ for (const page of [this.page, ...this.pages.values()]) {
2324
+ if (page === null || page === undefined || page.cdp?.closed === true) continue
2325
+ const target = String(page.targetId ?? '')
2326
+ if (target === '' || held.has(target)) continue
2327
+ if (samePlace(page.url, wanted)) return page
2328
+ }
2329
+ return null
2330
+ }
2331
+
2271
2332
  /**
2272
2333
  * @param {string} id - chat id.
2273
2334
  * @param {string} [url] - address to open in it.
2274
2335
  * @returns {Promise<SidecarPage>} a new tab for that chat.
2275
2336
  */
2276
2337
  async openTab(id, url) {
2338
+ this.expired?.delete(String(id))
2277
2339
  const had = this.pageFor(id)
2278
2340
  if (had !== null) {
2279
2341
  if (url !== undefined && url !== '') await had.navigate(url)
2280
2342
  return had
2281
2343
  }
2344
+ /* One address, one page: a browser that is already showing where this chat
2345
+ wants to go hands that page over instead of a second copy of the same
2346
+ address, which nobody would be able to tell apart in the tab list. */
2347
+ const twin = this.tabShowing(url, id)
2348
+ if (twin !== null) {
2349
+ this.pages.set(String(id), twin)
2350
+ this.claim(String(id), twin.targetId)
2351
+ this.touch()
2352
+ this.rememberTab(String(id), String(twin.url ?? url))
2353
+ return twin
2354
+ }
2282
2355
  if (this.page === null) await this.ensure()
2283
2356
  const port = this.page?.port ?? 0 // ui-copy-ignore
2284
2357
  if (port === 0) throw new Error('sidecar: no browser to open a tab in') // ui-copy-ignore
@@ -2640,7 +2713,25 @@ export class Sidecar {
2640
2713
  */
2641
2714
  async tabs(session = '') {
2642
2715
  const page = this.pageFor(session) ?? this.page
2643
- return page === null ? [] : page.tabs()
2716
+ if (page === null) return []
2717
+ const rows = await page.tabs()
2718
+ /* One address is one row: the base page can be showing the very place a
2719
+ chat's tab already shows, and two identical lines read as two pages. A
2720
+ page a chat holds represents that address; an unheld twin is left out,
2721
+ and the rows that stay keep the browser's order, which is the index
2722
+ space the tab tools address their rows by. */
2723
+ const held = new Set()
2724
+ for (const set of this.claims.values()) for (const target of set) held.add(String(target))
2725
+ const seen = new Set()
2726
+ const kept = []
2727
+ for (const row of [...rows].filter((item) => held.has(item.targetId)).concat(rows.filter((item) => !held.has(item.targetId)))) {
2728
+ const key = placeKey(row.url)
2729
+ if (key !== '' && seen.has(key)) continue
2730
+ if (key !== '') seen.add(key)
2731
+ kept.push(row)
2732
+ }
2733
+ kept.sort((a, b) => rows.indexOf(a) - rows.indexOf(b))
2734
+ return kept
2644
2735
  }
2645
2736
 
2646
2737
  /**
@@ -2836,7 +2927,18 @@ export class Sidecar {
2836
2927
  }
2837
2928
  }
2838
2929
 
2839
- touch() {
2930
+ /** @returns {number} how many panes are watching a picture right now. */
2931
+ watched() {
2932
+ let n = this.page?.frames?.size ?? 0
2933
+ for (const page of this.pages?.values() ?? []) n += page.frames?.size ?? 0
2934
+ return n
2935
+ }
2936
+
2937
+ /**
2938
+ * Starts the idle clock again.
2939
+ * @param {number} [ms] - how long a browser nobody watches may stay up.
2940
+ */
2941
+ touch(ms = IDLE_MS) {
2840
2942
  if (this.idle !== null) clearTimeout(this.idle)
2841
2943
  this.idle = setTimeout(() => {
2842
2944
  this.idle = null
@@ -2849,7 +2951,7 @@ export class Sidecar {
2849
2951
  return
2850
2952
  }
2851
2953
  void this.shutdown()
2852
- }, IDLE_MS)
2954
+ }, ms)
2853
2955
  this.idle.unref?.()
2854
2956
  }
2855
2957
 
@@ -3057,8 +3159,9 @@ export class Sidecar {
3057
3159
  question about which site it used to be. */
3058
3160
  const remembered = this.rememberedTab(id)
3059
3161
  const page = id === '' ? null : this.pageFor(id)
3060
- if (page === null || page === undefined) return { url: '', title: '', viewers: 0, alive: false, remembered } // ui-copy-ignore
3061
- return { url: String(page.url ?? ''), title: String(page.title ?? ''), viewers: page.frames?.size ?? 0, alive: true, remembered } // ui-copy-ignore
3162
+ const expired = this.expired?.has(id) === true
3163
+ if (page === null || page === undefined) return { url: '', title: '', viewers: 0, alive: false, expired, remembered } // ui-copy-ignore
3164
+ return { url: String(page.url ?? ''), title: String(page.title ?? ''), viewers: page.frames?.size ?? 0, alive: true, expired, remembered } // ui-copy-ignore
3062
3165
  }
3063
3166
 
3064
3167
  async state() {