dsh-inner-browser-use 0.27.0 → 0.34.28

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/sidecar.js CHANGED
@@ -5,11 +5,29 @@
5
5
  * and a page keeps working while the person looks somewhere else. The column
6
6
  * shows it as a screencast, so the tab is a viewer, not the browser.
7
7
  */
8
- import { spawn } from 'node:child_process'
9
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { execFileSync, spawn, spawnSync } from 'node:child_process'
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
10
10
  import { homedir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
 
13
+ /**
14
+ * Windows keeps a browser under Program Files (machine-wide) or LocalAppData
15
+ * (per-user), and Edge is Chromium too, so it speaks the same DevTools
16
+ * protocol. Chrome is still preferred: the list is read in order.
17
+ * @returns {string[]} candidates, most preferred first.
18
+ */
19
+ function windowsBrowserCandidates() {
20
+ const roots = [
21
+ process.env['PROGRAMFILES'],
22
+ process.env['PROGRAMFILES(X86)'],
23
+ process.env['LOCALAPPDATA'],
24
+ ].filter((root) => typeof root === 'string' && root !== '')
25
+ const out = []
26
+ for (const root of roots) out.push(join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'))
27
+ for (const root of roots) out.push(join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'))
28
+ return out
29
+ }
30
+
13
31
  /** Chrome builds this machine may have, most preferred first. */
14
32
  const CHROME_BINARIES = [
15
33
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -17,11 +35,15 @@ const CHROME_BINARIES = [
17
35
  '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
18
36
  '/usr/bin/google-chrome',
19
37
  '/usr/bin/chromium',
38
+ ...(process.platform === 'win32' ? windowsBrowserCandidates() : []),
20
39
  ]
21
40
 
22
41
  /** Viewport the screencast and the pointer share. Nothing here is a user-agent detail. */
23
42
  export const VIEW = { width: 1440, height: 900 }
24
43
 
44
+ /** What a person's Chrome on this machine sends. */
45
+ export const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36'
46
+
25
47
  /**
26
48
  * Pixels the mirror actually sends. The viewport is never downscaled: a
27
49
  * half-size JPEG turns 12px body text into mush in a column this narrow, and
@@ -44,6 +66,12 @@ function firstBinary() {
44
66
  return CHROME_BINARIES.find((path) => existsSync(path))
45
67
  }
46
68
 
69
+ /* A chat's tab address outlives a plugin reload: the browser is still running
70
+ with that tab, and the chat that returns should get its page back, not a
71
+ blank one. */
72
+ const tabsDir = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
73
+ const tabsFile = join(tabsDir, 'tabs.json')
74
+
47
75
  /** Root of everything this plugin keeps for the sidecar. */
48
76
  function rootDir() {
49
77
  const dir = join(homedir(), '.dsh', 'inner-browser-use', 'chrome')
@@ -51,6 +79,177 @@ function rootDir() {
51
79
  return dir
52
80
  }
53
81
 
82
+ /**
83
+ * Whether a target is not this page's to take: its own tab or one another chat
84
+ * is driving.
85
+ * @param {string} targetId - candidate target.
86
+ * @param {string} mine - the asking page's own target.
87
+ * @param {string[]} [foreign] - targets other chats drive.
88
+ * @returns {boolean} true when the candidate must be left alone.
89
+ */
90
+ export function isForeign(targetId, mine, foreign = []) {
91
+ const id = String(targetId ?? '')
92
+ if (id === '') return true
93
+ if (id === String(mine ?? '')) return true
94
+ return foreign.map(String).includes(id)
95
+ }
96
+
97
+ /**
98
+ * Where the cookies of a profile are kept in the browser's own storage.
99
+ */
100
+ /**
101
+ * The topmost process this plugin lives in: the application itself, not the
102
+ * node instance a reload replaces. A reload changes `process.pid`, so a watcher
103
+ * keyed to it kills the shared browser the moment the old instance goes — which
104
+ * is how the mirror went blank after a reload.
105
+ * @returns {number} the application pid, or 0 when it cannot be walked.
106
+ */
107
+ function appPid() {
108
+ let pid = process.pid
109
+ let top = pid
110
+ for (let hop = 0; hop < 12; hop += 1) {
111
+ let parent = 0
112
+ try {
113
+ parent = Number(String(execFileSync('ps', ['-o', 'ppid=', '-p', String(pid)], { encoding: 'utf8' })).trim())
114
+ } catch {
115
+ break
116
+ }
117
+ if (!Number.isInteger(parent) || parent <= 1) break
118
+ top = parent
119
+ pid = parent
120
+ }
121
+ return top === process.pid ? 0 : top
122
+ }
123
+
124
+ /**
125
+ * Whether a process command line is the application itself, rather than the node
126
+ * instance a reload replaces. A watcher keyed to the node instance would kill
127
+ * the shared browser on every reload, which is exactly what made the column go
128
+ * blank; a command line with `--expose-internals` is that instance.
129
+ * @param {string} command - what `ps -o command=` said.
130
+ * @returns {boolean} true for the app binary itself.
131
+ */
132
+ export function looksLikeApp(command) {
133
+ const line = String(command ?? '')
134
+ if (line.includes('--expose-internals')) return false
135
+ return line.includes('.app/Contents/MacOS/')
136
+ }
137
+
138
+ /**
139
+ * @param {number} pid - a process.
140
+ * @returns {string} its command line, or '' when it is gone.
141
+ */
142
+ function commandOf(pid) {
143
+ if (!Number.isInteger(pid) || pid <= 1) return ''
144
+ try {
145
+ return String(execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' })).trim()
146
+ } catch {
147
+ return ''
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Whether this run may arm the watcher at all: never on Windows, where the
153
+ * browser tree ends with the application through `taskkill /T`, and only inside
154
+ * the application itself, since a note keyed to anything else would close the
155
+ * shared browser on a plugin reload.
156
+ * @returns {boolean} true when `handoff()` can write a watcher note.
157
+ */
158
+ export function canArmWatcher() {
159
+ if (process.platform === 'win32') return false
160
+ const app = appPid()
161
+ return app !== 0 && looksLikeApp(commandOf(app)) === true
162
+ }
163
+
164
+ /** The watcher that closes the browser when the app goes: one per app run. */
165
+ function watcherFile(dir) {
166
+ return join(dir, 'watcher.json')
167
+ }
168
+
169
+ /**
170
+ * One line describing a browser that went away by itself, so the next person to
171
+ * look knows whether it was asked to stop, crashed, or was killed from outside.
172
+ * @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string}} gone - what the child reported.
173
+ * @returns {string} a line without a newline.
174
+ */
175
+ export function deathNote(gone) {
176
+ const at = new Date().toISOString()
177
+ const code = gone.code === null || gone.code === undefined ? 'none' : String(gone.code)
178
+ return `${at} browser pid ${String(gone.pid ?? 0)} profile ${String(gone.profile ?? 'default')} exit code ${code} signal ${String(gone.signal ?? 'none')}`
179
+ }
180
+
181
+ /**
182
+ * Appends one line to the browser log and never troubles the caller, because a
183
+ * browser that died while nothing was watching should not also break the call
184
+ * that noticed.
185
+ * @param {{pid?: number, code?: number|null, signal?: string|null, profile?: string, asked?: boolean}} gone - what happened.
186
+ * @param {string} [dir] - where the log lives; the state directory by default.
187
+ * @returns {void}
188
+ */
189
+ export function noteDeath(gone, dir = tabsDir) {
190
+ if (gone.asked === true) return
191
+ try {
192
+ mkdirSync(dir, { recursive: true })
193
+ appendFileSync(join(dir, 'browser.log'), `${deathNote(gone)}\n`)
194
+ } catch {
195
+ /* a log is a convenience, not a promise */
196
+ }
197
+ }
198
+
199
+ const COOKIE_FILES = ['Default/Cookies', 'Local State']
200
+
201
+ /**
202
+ * @param {string} dir - a profile directory.
203
+ * @returns {string} where its cookie snapshots live.
204
+ */
205
+ export function loginBackupDir(dir) {
206
+ // A Windows profile path has no '/', so splitting on it returned the whole
207
+ // path and the backup landed at `.../login/C:\Users\…` — a mkdir that fails.
208
+ const last = dir.split(/[\\/]/u).filter(Boolean).pop() ?? 'default'
209
+ return join(tabsDir, 'login', last)
210
+ }
211
+
212
+ /**
213
+ * Copies the cookie store aside while it is still good, so a profile that loses
214
+ * its cookies another way can be handed the ones that were working.
215
+ * @param {string} dir - a profile directory.
216
+ * @returns {number} how many snapshots are kept.
217
+ */
218
+ export function snapshotLogin(dir) {
219
+ const files = COOKIE_FILES.map((name) => ({ name, from: join(dir, name) })).filter((row) => existsSync(row.from))
220
+ if (files.length === 0) return 0
221
+ const to = loginBackupDir(dir)
222
+ mkdirSync(to, { recursive: true })
223
+ const stamp = String(Date.now())
224
+ for (const row of files) copyFileSync(row.from, join(to, `${stamp}-${row.name.replace(/\//g, '_')}`))
225
+ const mine = readdirSync(to).filter((name) => name.startsWith(`${stamp}-`))
226
+ const older = readdirSync(to).filter((name) => !mine.includes(name)).sort()
227
+ const generations = 5
228
+ const keep = older.slice(-generations * files.length)
229
+ for (const name of older) if (!keep.includes(name)) rmSync(join(to, name), { force: true })
230
+ return older.length + mine.length
231
+ }
232
+
233
+ /**
234
+ * Puts the newest snapshot back when the profile has no cookie store at all.
235
+ * @param {string} dir - a profile directory.
236
+ * @returns {boolean} whether anything was restored.
237
+ */
238
+ export function restoreLogin(dir) {
239
+ const cookie = join(dir, 'Default/Cookies')
240
+ if (existsSync(cookie) && statSync(cookie).size > 1000) return false
241
+ const from = loginBackupDir(dir)
242
+ if (!existsSync(from)) return false
243
+ const names = readdirSync(from).sort()
244
+ const newest = names.filter((name) => name.includes('-Default_Cookies')).slice(-1)[0]
245
+ const state = names.filter((name) => name.includes('-Local_State')).slice(-1)[0]
246
+ if (newest === undefined) return false
247
+ mkdirSync(join(dir, 'Default'), { recursive: true })
248
+ copyFileSync(join(from, newest), cookie)
249
+ if (state !== undefined) copyFileSync(join(from, state), join(dir, 'Local State'))
250
+ return true
251
+ }
252
+
54
253
  /** @param {string} name - profile name. @returns {string} its directory. */
55
254
  function profileDir(name) {
56
255
  const dir = join(rootDir(), String(name || 'default').replace(/[^\w.-]/g, '_'))
@@ -156,6 +355,7 @@ export class SidecarPage {
156
355
  this.pointerSinks = new Set()
157
356
  this.latest = ''
158
357
  this.seq = 0
358
+ this.lastFrameAt = 0
159
359
  /* Total JPEG bytes seen, for the bench and for noticing when a page got
160
360
  expensive to watch. */
161
361
  this.bytes = 0
@@ -185,6 +385,11 @@ export class SidecarPage {
185
385
  follows it, because a person looking at the column expects that too. */
186
386
  this.children = new Map()
187
387
  this.opened = null
388
+ /* The tab this page drives, and the tabs other chats drive. A tab another
389
+ chat owns is never a popup of this one: closing it, or navigating this
390
+ page to its address, would take a page away from somebody else. */
391
+ this.targetId = ''
392
+ this.siblings = () => []
188
393
  this.port = Number(port ?? 0) // ui-copy-ignore
189
394
  }
190
395
 
@@ -307,13 +512,15 @@ export class SidecarPage {
307
512
  * one exists and where it went.
308
513
  * @returns {Promise<{targetId: string, url: string}|null>} the popup.
309
514
  */
310
- async findPopup() {
515
+ async findPopup(foreign = []) {
311
516
  if (this.port <= 0) return null // ui-copy-ignore
312
- const main = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
517
+ const mine = await this.cdp.send('Target.getTargetInfo').then((info) => String(info?.targetInfo?.targetId ?? '')).catch(() => '')
518
+ const main = mine !== '' ? mine : this.targetId
519
+ const theirs = new Set(foreign.map(String))
313
520
  const list = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(1200) }) // ui-copy-ignore
314
521
  .then((response) => (response.ok ? response.json() : []))
315
522
  .catch(() => [])
316
- const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main)
523
+ const pages = (Array.isArray(list) ? list : []).filter((item) => item.type === 'page' && String(item.id ?? '') !== main && !theirs.has(String(item.id ?? '')))
317
524
  const popup = pages.find((item) => /^https?:|^file:/.test(String(item.url ?? '')))
318
525
  if (popup !== undefined) return { targetId: String(popup.id), url: String(popup.url) }
319
526
  const blank = pages[0]
@@ -343,14 +550,17 @@ export class SidecarPage {
343
550
  * @returns {Promise<string>} what happened, for the tool result.
344
551
  */
345
552
  async adoptOpened() {
553
+ const foreign = typeof this.siblings === 'function' ? this.siblings().map(String) : []
554
+ const theirs = new Set(foreign)
346
555
  let target = this.opened === null || this.opened === undefined ? null : { targetId: this.opened.targetId, url: this.opened.url }
347
556
  this.opened = null
557
+ if (target !== null && isForeign(target.targetId, this.targetId, foreign)) target = null
348
558
  if (target === null) {
349
559
  /* The target list is written before the popup finished loading, so give a
350
560
  click that opens one a moment to say where it is going. */
351
561
  for (let attempt = 0; attempt < 3 && target === null; attempt += 1) {
352
562
  if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 150))
353
- const found = await this.findPopup()
563
+ const found = await this.findPopup(foreign)
354
564
  if (found !== null && found.url !== '') target = found
355
565
  else if (found !== null && attempt === 2) target = found
356
566
  }
@@ -456,16 +666,14 @@ export class SidecarPage {
456
666
 
457
667
  /** Keep one listener that fans frames out to every viewer. */
458
668
  async start() {
459
- await this.cdp.send('Network.enable').catch(() => {})
669
+ this.targetId = String(
670
+ await this.cdp.send('Target.getTargetInfo').then((info) => info?.targetInfo?.targetId ?? '').catch(() => ''),
671
+ )
460
672
  /* A click that downloads should leave a file, not nothing: point downloads
461
673
  at the profile so the driver can report where they went. */
462
674
  const folder = downloadsDir(profileDir(this.profile))
463
675
  mkdirSync(folder, { recursive: true })
464
676
  this.downloadsFolder = folder
465
- await this.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: folder }).catch(() => {})
466
- /* Popups and out-of-process frames are separate targets. Attaching without
467
- waiting keeps them usable, and lets the mirror follow a new tab. */
468
- await this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => {})
469
677
  this.cdp.on((message) => {
470
678
  this.watch(message)
471
679
  if (message.method !== 'Page.screencastFrame') return
@@ -473,6 +681,7 @@ export class SidecarPage {
473
681
  void this.cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {})
474
682
  if (typeof data !== 'string' || data === '') return
475
683
  this.latest = data
684
+ this.lastFrameAt = Date.now()
476
685
  this.seq += 1
477
686
  this.bytes += data.length
478
687
  if (metadata?.timestamp !== undefined && metadata.timestamp * 1000 < this.touched - 60_000) return
@@ -503,23 +712,39 @@ export class SidecarPage {
503
712
  }
504
713
  if (message.method === 'Page.loadEventFired') void this.readTitle()
505
714
  })
506
- await this.cdp.send('Page.enable')
715
+ /* None of these settings depends on another: asking for all of them at
716
+ once is one round trip instead of four, and a cold start is where it
717
+ shows. */
718
+ await Promise.all([
719
+ this.cdp.send('Page.enable'),
720
+ this.cdp.send('Network.enable').catch(() => {}),
721
+ this.cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: folder }).catch(() => {}),
722
+ /* Popups and out-of-process frames are separate targets. Attaching without
723
+ waiting keeps them usable, and lets the mirror follow a new tab. */
724
+ this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => {}),
725
+ ])
507
726
  /* An instance left behind by a stopped app can still be sitting on a
508
727
  dialog; a paused renderer answers nothing, so clear it first. */
509
728
  await Promise.race([
510
729
  this.cdp.send('Page.handleJavaScriptDialog', { accept: false }).catch(() => {}),
511
730
  new Promise((resolve) => setTimeout(resolve, 800)),
512
731
  ])
513
- await this.cdp.send('Runtime.enable')
514
- await this.cdp.send('Emulation.setDeviceMetricsOverride', {
515
- width: VIEW.width, // ui-copy-ignore
516
- height: VIEW.height, // ui-copy-ignore
517
- deviceScaleFactor: 1,
518
- mobile: false, // ui-copy-ignore
519
- })
732
+ /* The metrics and the version string are independent too: the UA override
733
+ only needs the version, not a turn of its own. */
734
+ const [, version] = await Promise.all([
735
+ this.cdp.send('Runtime.enable').then(() =>
736
+ this.cdp.send('Emulation.setDeviceMetricsOverride', {
737
+ width: VIEW.width, // ui-copy-ignore
738
+ height: VIEW.height, // ui-copy-ignore
739
+ deviceScaleFactor: 1,
740
+ mobile: false, // ui-copy-ignore
741
+ }),
742
+ ),
743
+ this.cdp.send('Browser.getVersion').catch(() => ({ product: '' })),
744
+ ])
520
745
  /* A headless build announces itself in the UA, and some sites refuse that
521
746
  string outright. The page is a normal Chrome here, so it says so. */
522
- const { product } = await this.cdp.send('Browser.getVersion').catch(() => ({ product: '' }))
747
+ const product = String(version?.product ?? '')
523
748
  if (String(product).includes('HeadlessChrome')) {
524
749
  const version = String(product).replace(/.*HeadlessChrome\//, '')
525
750
  await this.cdp.send('Emulation.setUserAgentOverride', {
@@ -550,7 +775,12 @@ export class SidecarPage {
550
775
 
551
776
  /** @returns {Promise<void>} resolves once the page is casting. */
552
777
  async cast() {
553
- if (this.casting) return
778
+ if (this.casting) {
779
+ /* A page nobody repaints still owes the pane a picture, so silence for a
780
+ few seconds means the cast died rather than being idle. */
781
+ if (Date.now() - this.lastFrameAt < 3000) return
782
+ this.casting = false
783
+ }
554
784
  this.casting = true
555
785
  /* Full-resolution mirror at a quality that keeps small text readable;
556
786
  `optimizeForSpeed` keeps the encoder from adding to the round trip. */
@@ -564,6 +794,38 @@ export class SidecarPage {
564
794
  }).catch(() => {
565
795
  this.casting = false
566
796
  })
797
+ void this.still()
798
+ }
799
+
800
+ /** Sends one full-frame picture now: a mirror that waits for motion shows an empty pane on a page that is not moving. */
801
+ async still() {
802
+ let data = ''
803
+ try {
804
+ const shot = await this.cdp.send('Page.captureScreenshot', { format: 'jpeg', quality: 72, fromSurface: true })
805
+ data = String(shot?.data ?? '')
806
+ } catch {
807
+ return
808
+ }
809
+ if (data === '' || this.frames.size === 0) return
810
+ this.latest = data
811
+ this.lastFrameAt = Date.now()
812
+ this.seq += 1
813
+ const frame = {
814
+ seq: this.seq, // ui-copy-ignore
815
+ w: VIEW.width, // ui-copy-ignore
816
+ h: VIEW.height, // ui-copy-ignore
817
+ jpeg: data, // ui-copy-ignore
818
+ url: this.url, // ui-copy-ignore
819
+ title: this.title, // ui-copy-ignore
820
+ pointer: this.pointer, // ui-copy-ignore
821
+ }
822
+ for (const sink of this.frames) {
823
+ try {
824
+ sink(frame)
825
+ } catch {
826
+ /* a closed viewer is dropped by its own stream handler */
827
+ }
828
+ }
567
829
  }
568
830
 
569
831
  async stopCast() {
@@ -580,12 +842,34 @@ export class SidecarPage {
580
842
  this.touched = Date.now()
581
843
  this.lastUrl = String(url)
582
844
  await this.cdp.send('Page.navigate', { url })
845
+ /* A blank page is where the wait ends, not a place still to arrive: asking
846
+ for about:blank and then waiting for a non-blank address spent the whole
847
+ 20 s budget, which is how a brand-new chat's first tab took forever. */
848
+ if (String(url) === '' || String(url) === 'about:blank') {
849
+ await wait(80)
850
+ await this.readTitle()
851
+ return { url: this.url, title: this.title }
852
+ }
583
853
  for (let i = 0; i < 200; i += 1) {
584
854
  await wait(100)
585
855
  await this.readTitle()
586
856
  if (this.url !== '' && this.url !== 'about:blank') break
587
857
  }
588
- return { url: this.url, title: this.title }
858
+ /* A brand-new browser sometimes answers the very first navigation with its
859
+ own error page: the network stack is still coming up. One reload of that
860
+ address is the difference between the chat seeing the site and the chat
861
+ seeing a browser error in the mirror. */
862
+ if (this.url.startsWith('chrome-error://')) {
863
+ await wait(300)
864
+ await this.cdp.send('Page.reload', {}).catch(() => {})
865
+ for (let i = 0; i < 80; i += 1) {
866
+ await wait(100)
867
+ await this.readTitle()
868
+ if (this.url !== '' && !this.url.startsWith('chrome-error://') && this.url !== 'about:blank') break
869
+ }
870
+ }
871
+ const bad = this.url.startsWith('chrome-error://') ? 'the page did not load' : ''
872
+ return bad === '' ? { url: this.url, title: this.title } : { url: this.url, title: this.title, bad }
589
873
  }
590
874
 
591
875
  /**
@@ -749,6 +1033,56 @@ export class SidecarPage {
749
1033
  })
750
1034
  }
751
1035
 
1036
+ /**
1037
+ * Type the way a person does: one character at a time, uneven gaps, a longer
1038
+ * pause where a person would think, and no clipboard paste at all. Paste is
1039
+ * the single event that tells a site a machine filled the box; per-character
1040
+ * key events do not. Longer messages are typed in small bursts so a whole
1041
+ * page of text does not take a minute.
1042
+ * @param {string} text - any unicode, including Chinese.
1043
+ * @param {{burst?: number, min?: number, max?: number}} [options] - pacing.
1044
+ * @returns {Promise<void>} resolves once the last character was accepted.
1045
+ */
1046
+ async humanType(text, options = {}) {
1047
+ this.touched = Date.now()
1048
+ const chars = Array.from(String(text))
1049
+ /* A long message is typed faster per burst: the opening still looks like
1050
+ fingers, but nobody wants to wait fifteen seconds for a paragraph. */
1051
+ const long = chars.length > 160
1052
+ const burst = Math.max(1, Number(options.burst ?? (long ? 8 : 3)))
1053
+ const min = Number(options.min ?? (long ? 10 : 26))
1054
+ const max = Math.max(min + 1, Number(options.max ?? (long ? 46 : 110)))
1055
+ const gap = () => min + Math.random() * (max - min)
1056
+ let at = 0
1057
+ while (at < chars.length) {
1058
+ const size = at < 12 ? 1 : burst
1059
+ const chunk = chars.slice(at, at + size).join('')
1060
+ /* One key event per character is what a keyboard produces. A multi-letter
1061
+ key event would only deliver its first letter, so a run of Latin text
1062
+ is pressed key by key; a run of Chinese arrives as an input-method
1063
+ commit, which is one event for a whole phrase. */
1064
+ if (size > 1 && /[\u3040-\u9fff\uac00-\ud7af]/.test(chunk)) {
1065
+ await this.input('Input.insertText', { text: chunk })
1066
+ } else {
1067
+ for (const one of Array.from(chunk)) {
1068
+ await this.input('Input.dispatchKeyEvent', {
1069
+ type: 'keyDown', // ui-copy-ignore
1070
+ key: one, // ui-copy-ignore
1071
+ text: one, // ui-copy-ignore
1072
+ unmodifiedText: one, // ui-copy-ignore
1073
+ windowsVirtualKeyCode: 0,
1074
+ nativeVirtualKeyCode: 0,
1075
+ }).catch(() => {})
1076
+ }
1077
+ }
1078
+ at += size
1079
+ const last = chars[at - 1] ?? ''
1080
+ /* Stop longer after a full stop or a comma, the way a person does. */
1081
+ const pause = /[。!?,、;:\n.,!?;:]/.test(last) ? gap() * 2.2 + 90 : gap()
1082
+ await wait(at < chars.length ? pause : 0)
1083
+ }
1084
+ }
1085
+
752
1086
  /** @param {string} text - any unicode, including Chinese. @returns {Promise<void>} resolves when typed. */
753
1087
  async insertText(text) {
754
1088
  this.touched = Date.now()
@@ -900,8 +1234,15 @@ export class Sidecar {
900
1234
  constructor(options = {}) {
901
1235
  this.logger = options.logger
902
1236
  this.child = null
1237
+ this.stopping = false
903
1238
  this.cdp = null
904
1239
  this.page = null
1240
+ /* One tab per chat: a second chat opening an address gets a tab of its
1241
+ own, so neither can navigate the other's page away. */
1242
+ this.pages = new Map()
1243
+ /* A tab being opened for a chat: a second call for the same chat waits for
1244
+ it instead of opening a twin. */
1245
+ this.opening = new Map()
905
1246
  /* A measurement or a test may want a profile of its own; the person's
906
1247
  browser keeps the default one. */
907
1248
  this.profile = String(options.profile ?? 'default')
@@ -971,10 +1312,159 @@ export class Sidecar {
971
1312
  }
972
1313
 
973
1314
  /**
974
- * @param {{profile?: string, headless?: boolean, url?: string}} [options] - how to start.
1315
+ * The tab a chat drives. A named chat gets a tab of its own, made once and
1316
+ * kept; an unnamed caller (a test, a screenshot script) drives the first tab.
1317
+ * @param {string} session - chat id, empty for the default tab.
1318
+ * @returns {SidecarPage | null} the tab, if the browser is up.
1319
+ */
1320
+ pageFor(session) {
1321
+ const id = String(session ?? '')
1322
+ if (id === '') return this.page
1323
+ const mine = this.pages.get(id)
1324
+ if (mine !== undefined && mine.cdp?.closed !== true) return mine
1325
+ if (mine !== undefined) this.pages.delete(id)
1326
+ return null
1327
+ }
1328
+
1329
+ /**
1330
+ * Keep a chat's tab address on disk, so a reload can bring the page back.
1331
+ * @param {string} session - chat id.
1332
+ * @param {string} url - address that chat's tab is showing.
1333
+ */
1334
+ rememberTab(session, url) {
1335
+ const id = String(session ?? '')
1336
+ const address = String(url ?? '')
1337
+ /* Only chats, and only addresses: a stray key from a probe or an older run
1338
+ must not sit in this file pretending to be a chat that can be restored. */
1339
+ if (!/^session-/.test(id) || address === '' || address === 'about:blank') return
1340
+ let saved = {}
1341
+ try {
1342
+ saved = JSON.parse(readFileSync(tabsFile, 'utf8')) ?? {}
1343
+ } catch {
1344
+ saved = {}
1345
+ }
1346
+ if (saved[id] === address) return
1347
+ saved[id] = address
1348
+ try {
1349
+ mkdirSync(tabsDir, { recursive: true })
1350
+ writeFileSync(tabsFile, JSON.stringify(saved))
1351
+ } catch {
1352
+ /* a browser that cannot write its note still works */
1353
+ }
1354
+ }
1355
+
1356
+ /**
1357
+ * @param {string} session - a chat id.
1358
+ * @returns {string} the address that chat last had, or '' when it never had one.
1359
+ */
1360
+ rememberedTab(session) {
1361
+ const id = String(session ?? '')
1362
+ if (!/^session-/.test(id)) return ''
1363
+ try {
1364
+ const saved = JSON.parse(readFileSync(tabsFile, 'utf8'))
1365
+ return String(saved?.[id] ?? '')
1366
+ } catch {
1367
+ return ''
1368
+ }
1369
+ }
1370
+
1371
+ /**
1372
+ * Give a chat its own tab, on the same browser: logins are shared, addresses
1373
+ * are not.
1374
+ * @param {string} session - chat id.
1375
+ * @param {string} [url] - address to open in it.
1376
+ * @returns {Promise<SidecarPage>} its tab.
1377
+ */
1378
+ async tabFor(session, url) {
1379
+ const id = String(session ?? '')
1380
+ const had = this.pageFor(id)
1381
+ if (had !== null) {
1382
+ if (url !== undefined && url !== '') await had.navigate(url)
1383
+ return had
1384
+ }
1385
+ /* Two calls that start together are one chat, and a chat has one tab: the
1386
+ second waits for the first tab instead of opening a twin nobody holds. */
1387
+ const underway = this.opening?.get(id)
1388
+ if (underway !== undefined) {
1389
+ const page = await underway
1390
+ const want = String(url ?? '')
1391
+ if (want !== '' && String(page.url ?? '') !== want) await page.navigate(want)
1392
+ return page
1393
+ }
1394
+ const work = this.openTab(id, url).finally(() => {
1395
+ this.opening?.delete(id)
1396
+ })
1397
+ this.opening?.set(id, work)
1398
+ return work
1399
+ }
1400
+
1401
+ /**
1402
+ * @param {string} id - chat id.
1403
+ * @param {string} [url] - address to open in it.
1404
+ * @returns {Promise<SidecarPage>} a new tab for that chat.
1405
+ */
1406
+ async openTab(id, url) {
1407
+ const had = this.pageFor(id)
1408
+ if (had !== null) {
1409
+ if (url !== undefined && url !== '') await had.navigate(url)
1410
+ return had
1411
+ }
1412
+ if (this.page === null) await this.ensure()
1413
+ const port = this.page?.port ?? 0 // ui-copy-ignore
1414
+ if (port === 0) throw new Error('sidecar: no browser to open a tab in') // ui-copy-ignore
1415
+ /* A chat that was given a tab without an address keeps looking at what the
1416
+ browser was already showing, rather than at a blank page nobody asked for. */
1417
+ const remembered = this.rememberedTab(id)
1418
+ const address = url === undefined || url === '' ? String(remembered !== '' ? remembered : this.page?.lastUrl ?? this.lastUrl ?? 'about:blank') : String(url)
1419
+ const created = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent(address)}`, { method: 'PUT', signal: AbortSignal.timeout(3000) }) // ui-copy-ignore
1420
+ if (!created.ok) throw new Error('sidecar: could not open a tab')
1421
+ const target = await created.json()
1422
+ if (typeof target.webSocketDebuggerUrl !== 'string') throw new Error('sidecar: new tab has no socket')
1423
+ const cdp = new Cdp(target.webSocketDebuggerUrl)
1424
+ await cdp.ready
1425
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
1426
+ this.pages.set(id, page)
1427
+ this.touch()
1428
+ this.rememberTab(id, address)
1429
+ return page
1430
+ }
1431
+
1432
+ /**
1433
+ * @param {{profile?: string, headless?: boolean, url?: string, session?: string}} [options] - how to start.
975
1434
  * @returns {Promise<SidecarPage>} the page being driven.
976
1435
  */
977
1436
  async ensure(options = {}) {
1437
+ const page = await this.ensurePage(options)
1438
+ const id = String(options.session ?? '')
1439
+ if (id === '') return page
1440
+ const mine = this.pageFor(id)
1441
+ if (mine !== null) {
1442
+ /* A chat's tab can die on its own: a crash, a browser another call
1443
+ restarted, a tab closed by hand. Handing that corpse back leaves the
1444
+ mirror on a blank picture forever, so it has to answer before it is
1445
+ trusted again. */
1446
+ const alive = await mine.ping()
1447
+ if (alive.ok) {
1448
+ /* A blank tab after a restart is not where this chat was: the address it
1449
+ last had is on disk, and that is the one worth returning to. */
1450
+ if (String(mine.url ?? '') === 'about:blank') {
1451
+ const back = this.rememberedTab(id)
1452
+ if (back !== '') await mine.navigate(back)
1453
+ }
1454
+ const wanted = String(options.url ?? '')
1455
+ if (wanted !== '' && String(mine.url) !== wanted) await mine.navigate(wanted)
1456
+ this.rememberTab(id, String(mine.url ?? wanted))
1457
+ return mine
1458
+ }
1459
+ this.pages.delete(id)
1460
+ return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
1461
+ }
1462
+ /* A new tab for a chat that had one before opens where that chat was: a
1463
+ blank mirror is not a place, and the first call should not land on one. */
1464
+ return this.tabFor(id, options.url === undefined || options.url === '' ? this.rememberedTab(id) : options.url)
1465
+ }
1466
+
1467
+ async ensurePage(options = {}) {
978
1468
  if (options.profile !== undefined) this.profile = options.profile
979
1469
  const wanted = options.headless === undefined ? this.headless : options.headless !== false
980
1470
  const modeChanged = wanted !== this.headless
@@ -1011,6 +1501,12 @@ export class Sidecar {
1011
1501
  }
1012
1502
  }
1013
1503
  this.touch()
1504
+ const id = String(options.session ?? '')
1505
+ if (id !== '') {
1506
+ /* Another chat may be looking at another tab: this one gets its own. */
1507
+ const mine = await this.tabFor(id, options.url)
1508
+ return mine
1509
+ }
1014
1510
  if (options.url !== undefined && options.url !== '') await this.page.navigate(options.url)
1015
1511
  return this.page
1016
1512
  }
@@ -1038,18 +1534,28 @@ export class Sidecar {
1038
1534
  /** @param {string | undefined} url - first address. @returns {Promise<SidecarPage>} the page. */
1039
1535
  async launch(url) {
1040
1536
  const dir = profileDir(this.profile)
1537
+ /* A profile whose cookie store went missing would look signed out at the
1538
+ next step even though the person never signed out, so the working copy
1539
+ is put back before the browser is asked to read it. */
1540
+ restoreLogin(dir)
1041
1541
  let port = await this.runningPort(dir) // ui-copy-ignore
1042
1542
  if (port === 0) port = await this.spawnChrome(dir) // ui-copy-ignore
1043
1543
  const ws = await this.pageSocket(port) // ui-copy-ignore
1044
1544
  const cdp = new Cdp(ws)
1045
1545
  await cdp.ready
1046
1546
  this.cdp = cdp
1047
- this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1547
+ this.page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
1048
1548
  if (url !== undefined && url !== '') this.lastUrl = String(url)
1049
1549
  await this.restoreSessionCookies()
1050
1550
  await this.page.cast()
1051
1551
  if (url !== undefined && url !== '') await this.page.navigate(url)
1052
1552
  this.touch()
1553
+ /* Sign-ins outlive this browser: what works now is worth a copy, taken
1554
+ while the cookies are known good rather than after they are lost. */
1555
+ snapshotLogin(dir)
1556
+ this.backup?.()
1557
+ this.backup = setInterval(() => snapshotLogin(dir), 10 * 60_000)
1558
+ this.backup.unref?.()
1053
1559
  this.logger?.info?.('dsh-inner-browser-use: sidecar up on port %s (%s)', port, this.headless ? 'headless' : 'windowed') // ui-copy-ignore
1054
1560
  return this.page
1055
1561
  }
@@ -1113,11 +1619,25 @@ export class Sidecar {
1113
1619
  '--password-store=basic',
1114
1620
  '--lang=zh-CN',
1115
1621
  `--window-size=${VIEW.width},${VIEW.height}`,
1622
+ /* A windowless build says "HeadlessChrome" in every request, and a site
1623
+ that checks for it answers the whole browser with a parked page. The
1624
+ browser is windowless, not a robot: say what a person's Chrome says. */
1625
+ `--user-agent=${DESKTOP_UA}`,
1626
+ /* A site that refuses a headless build still gets a window, but never on
1627
+ the desktop: the mirror in the column is the only thing to look at. */
1628
+ '--window-position=-3000,-3000',
1116
1629
  'about:blank',
1117
1630
  ]
1118
1631
  if (this.headless) args.unshift('--headless=new')
1632
+ /* A browser started again after a stop is a new one: its death is news
1633
+ again, and the flag left behind by the stop must not silence it. */
1634
+ this.stopping = false
1119
1635
  const child = spawn(binary, args, { detached: false, stdio: 'ignore' })
1120
- child.on('exit', () => {
1636
+ const born = Date.now()
1637
+ child.on('exit', (code, signal) => {
1638
+ /* A browser that ends without being asked is the one case nobody can see:
1639
+ Chrome's own stderr goes nowhere, so leave a line behind. */
1640
+ noteDeath({ pid: child.pid, code, signal, profile: this.profile, asked: this.stopping === true || Date.now() - born < 3000 })
1121
1641
  if (this.child === child) {
1122
1642
  this.child = null
1123
1643
  this.cdp?.close()
@@ -1149,8 +1669,9 @@ export class Sidecar {
1149
1669
  * The browser's pages, in the order the browser lists them.
1150
1670
  * @returns {Promise<Array<{targetId: string, title: string, url: string, current: boolean}>>} the tabs.
1151
1671
  */
1152
- async tabs() {
1153
- return this.page === null ? [] : this.page.tabs()
1672
+ async tabs(session = '') {
1673
+ const page = this.pageFor(session) ?? this.page
1674
+ return page === null ? [] : page.tabs()
1154
1675
  }
1155
1676
 
1156
1677
  /**
@@ -1158,15 +1679,47 @@ export class Sidecar {
1158
1679
  * @param {number} index - a row from `tabs()`.
1159
1680
  * @returns {Promise<SidecarPage>} the page now being driven.
1160
1681
  */
1161
- async useTab(index) {
1162
- const list = await this.tabs()
1682
+ /**
1683
+ * Tells a page which targets belong to other chats, so a popup lookup cannot
1684
+ * pick somebody else's tab.
1685
+ * @param {SidecarPage} page - a page this browser drives.
1686
+ * @returns {SidecarPage} the same page.
1687
+ */
1688
+ bind(page) {
1689
+ page.siblings = () =>
1690
+ [...this.pages.values(), this.page]
1691
+ .filter((other) => other !== null && other !== undefined && other !== page)
1692
+ .map((other) => String(other.targetId ?? ''))
1693
+ .filter((id) => id !== '')
1694
+ return page
1695
+ }
1696
+
1697
+ async useTab(index, session = '') {
1698
+ const list = await this.tabs(session)
1163
1699
  const tab = list[Number(index)]
1164
1700
  if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1701
+ const owned = String(session ?? '') !== ''
1702
+ const mine = owned ? this.pageFor(session) : null
1703
+ if (tab.current && mine !== null) return mine
1704
+ if (tab.current && !owned) return this.page
1165
1705
  if (tab.current) return this.page
1166
- const was = this.page
1706
+ const was = mine ?? this.page
1707
+ /* A chat that owns a tab keeps owning one: switching moves the chat, not
1708
+ the browser's first tab that a different chat may be driving. */
1709
+ if (mine !== null) {
1710
+ const cdp = new Cdp(tab.webSocketUrl)
1711
+ await cdp.ready
1712
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, mine.port).start()) // ui-copy-ignore
1713
+ this.pages.set(String(session), page)
1714
+ mine.cdp.send('Page.stopScreencast').catch(() => {})
1715
+ mine.cdp.close()
1716
+ await page.cast()
1717
+ this.touch()
1718
+ return page
1719
+ }
1167
1720
  const cdp = new Cdp(tab.webSocketUrl)
1168
1721
  await cdp.ready
1169
- const page = await new SidecarPage(cdp, this.profile, was.port).start() // ui-copy-ignore
1722
+ const page = await this.bind(await new SidecarPage(cdp, this.profile, was.port).start()) // ui-copy-ignore
1170
1723
  this.cdp = cdp
1171
1724
  this.page = page
1172
1725
  this.lastUrl = tab.url
@@ -1181,18 +1734,25 @@ export class Sidecar {
1181
1734
  * Close a tab; the mirror moves to a neighbour when the closed one was shown.
1182
1735
  * @param {number} index - a row from `tabs()`.
1183
1736
  */
1184
- async closeTab(index) {
1185
- const list = await this.tabs()
1737
+ async closeTab(index, session = '') {
1738
+ const list = await this.tabs(session)
1186
1739
  const tab = list[Number(index)]
1187
1740
  if (tab === undefined) throw new Error(`sidecar: no tab ${String(index)}`)
1188
- let port = this.page?.port ?? 0 // ui-copy-ignore
1741
+ const mine = String(session ?? '') === '' ? null : this.pageFor(session)
1742
+ let port = (mine ?? this.page)?.port ?? 0 // ui-copy-ignore
1189
1743
  let alone = true
1190
1744
  if (tab.current) {
1191
1745
  const other = list.findIndex((item) => !item.current)
1192
1746
  alone = other < 0
1193
- if (!alone) await this.useTab(other)
1747
+ if (!alone) await this.useTab(other, session)
1194
1748
  }
1195
1749
  if (port > 0) await fetch(`http://127.0.0.1:${port}/json/close/${tab.targetId}`, { signal: AbortSignal.timeout(1000) }).catch(() => {}) // ui-copy-ignore
1750
+ if (alone && mine !== null) {
1751
+ /* The chat still needs somewhere to go: a fresh tab in the same browser. */
1752
+ this.pages.delete(String(session))
1753
+ await this.tabFor(String(session), 'about:blank')
1754
+ return
1755
+ }
1196
1756
  if (alone) {
1197
1757
  await this.shutdown()
1198
1758
  await this.ensure()
@@ -1210,7 +1770,7 @@ export class Sidecar {
1210
1770
  const cdp = new Cdp(target.webSocketDebuggerUrl)
1211
1771
  await cdp.ready
1212
1772
  this.cdp = cdp
1213
- this.page = await new SidecarPage(cdp, this.profile, port).start() // ui-copy-ignore
1773
+ this.page = await this.bind(await new SidecarPage(cdp, this.profile, port).start()) // ui-copy-ignore
1214
1774
  await this.page.cast()
1215
1775
  this.touch()
1216
1776
  return this.page
@@ -1241,8 +1801,8 @@ export class Sidecar {
1241
1801
  * @param {(frame: any) => void} sink - called per frame.
1242
1802
  * @returns {Promise<() => void>} detach.
1243
1803
  */
1244
- async watch(sink) {
1245
- const page = await this.ensure()
1804
+ async watch(sink, session = '') {
1805
+ const page = await this.ensure({ session })
1246
1806
  page.frames.add(sink)
1247
1807
  page.pointerSinks.add(sink)
1248
1808
  await page.cast()
@@ -1259,7 +1819,9 @@ export class Sidecar {
1259
1819
  this.idle = null
1260
1820
  /* Someone is watching the mirror: the page stays up, and the clock starts
1261
1821
  again so the browser is closed only once it is truly unused. */
1262
- if ((this.page?.frames.size ?? 0) > 0) {
1822
+ let watched = this.page?.frames.size ?? 0
1823
+ for (const page of this.pages.values()) watched += page.frames?.size ?? 0
1824
+ if (watched > 0) {
1263
1825
  this.touch()
1264
1826
  return
1265
1827
  }
@@ -1269,20 +1831,104 @@ export class Sidecar {
1269
1831
  }
1270
1832
 
1271
1833
  /** @returns {Promise<void>} resolves once Chrome is gone. */
1834
+ /**
1835
+ * Leaves the browser running for whoever loads this plugin next, and puts the
1836
+ * sign-in on disk first. A plugin reload used to close Chrome, so the column
1837
+ * lost the page and every sign-in paid for a fresh launch; the browser is
1838
+ * meant to outlive the driver. A detached watcher closes the browser once
1839
+ * this app is really gone, so nothing is left behind.
1840
+ * @returns {Promise<void>} done, with the browser still up.
1841
+ */
1842
+ async handoff() {
1843
+ this.stopping = true
1844
+ if (this.page !== null) await this.saveSessionCookies()
1845
+ if (this.backup !== undefined) clearInterval(this.backup)
1846
+ this.backup = undefined
1847
+ try {
1848
+ snapshotLogin(profileDir(this.profile))
1849
+ } catch {
1850
+ /* a profile that cannot be copied is not a reason to drop the browser */
1851
+ }
1852
+ const chrome = Number(this.child?.pid ?? 0)
1853
+ if (chrome === 0) return
1854
+ /* One watcher per application run, watching the application rather than
1855
+ this instance: a plugin reload is not the app closing. */
1856
+ /* Read at call time, so a test can point the note somewhere harmless. */
1857
+ const dir = process.env.DSH_INNER_BROWSER_STATE_DIR || tabsDir
1858
+ const app = appPid()
1859
+ /* A watcher may only watch the application: one keyed to the node instance
1860
+ a reload replaces closes the browser at that reload. When the walk cannot
1861
+ name the app, no watcher is armed at all rather than a wrong one. */
1862
+ if (app === 0 || looksLikeApp(commandOf(app)) === false) return
1863
+ try {
1864
+ const said = JSON.parse(readFileSync(watcherFile(dir), 'utf8'))
1865
+ const watching = Number(said?.appPid ?? 0)
1866
+ const alive = (() => {
1867
+ try {
1868
+ process.kill(Number(said?.pid ?? 0), 0)
1869
+ return true
1870
+ } catch {
1871
+ return false
1872
+ }
1873
+ })()
1874
+ /* Same run and same browser: the watcher already covers it. A browser
1875
+ that was restarted needs the note pointed at the new pid, or the app
1876
+ going away would leave the new one running. */
1877
+ if (alive && Number(said?.chrome ?? 0) === chrome && watching === Number(said?.appPid) && (app === 0 || watching === app)) return
1878
+ if (alive) {
1879
+ try {
1880
+ process.kill(Number(said?.pid ?? 0))
1881
+ } catch {
1882
+ /* a watcher that already left needs no goodbye */
1883
+ }
1884
+ }
1885
+ } catch {
1886
+ /* no watcher yet, or one whose note is unreadable */
1887
+ }
1888
+ /* Detached and unref'd: the app must not wait for this, and it must outlive
1889
+ the plugin instance it was started for. Windows has no `/bin/sh`, so there
1890
+ the lifetime rides on the browser itself: `taskkill /T` already ends the
1891
+ whole tree when this process is gone, and spawning a POSIX watcher here
1892
+ would only throw ENOENT and be swallowed. */
1893
+ if (process.platform === 'win32') return
1894
+ try {
1895
+ const guard = spawn('/bin/sh', ['-c', `while kill -0 ${app} 2>/dev/null; do sleep 5; done; kill ${chrome} 2>/dev/null`], {
1896
+ detached: true,
1897
+ stdio: 'ignore',
1898
+ })
1899
+ guard.unref()
1900
+ mkdirSync(dir, { recursive: true })
1901
+ writeFileSync(watcherFile(dir), JSON.stringify({ appPid: app, pid: guard.pid ?? 0, chrome, at: Date.now() }))
1902
+ } catch {
1903
+ /* a watcher that cannot start only means the browser outlives the app */
1904
+ }
1905
+ }
1906
+
1272
1907
  async shutdown() {
1273
1908
  /* Ask Chrome to close first: a browser that is killed outright may lose the
1274
1909
  last minute of cookies, and keeping the person signed in is the point. */
1275
1910
  if (this.page !== null) await this.saveSessionCookies()
1911
+ if (this.backup !== undefined) clearInterval(this.backup)
1912
+ this.backup = undefined
1913
+ snapshotLogin(profileDir(this.profile))
1276
1914
  if (this.cdp !== null) {
1277
1915
  await this.cdp.send('Browser.close').catch(() => {})
1278
1916
  await new Promise((resolve) => setTimeout(resolve, 400))
1279
1917
  }
1280
1918
  this.page = null
1919
+ this.pages.clear()
1281
1920
  this.cdp?.close()
1282
1921
  this.cdp = null
1283
1922
  if (this.child !== null) {
1284
1923
  try {
1285
- this.child.kill('SIGTERM')
1924
+ /* A browser on Windows is a process tree: terminating the launcher alone
1925
+ leaves renderers and the profile lock behind. Browser.close above is
1926
+ the graceful path; this is the one for a browser that ignored it. */
1927
+ if (process.platform === 'win32' && Number.isInteger(this.child.pid)) {
1928
+ spawnSync('taskkill', ['/pid', String(this.child.pid), '/T', '/F'], { stdio: 'ignore' })
1929
+ } else {
1930
+ this.child.kill('SIGTERM')
1931
+ }
1286
1932
  } catch {
1287
1933
  /* already gone */
1288
1934
  }
@@ -1319,9 +1965,20 @@ export class Sidecar {
1319
1965
  return
1320
1966
  }
1321
1967
  if (!Array.isArray(jar)) return
1968
+ /* A cookie the browser already holds is newer than the note: the site may
1969
+ have rotated its session id since the note was written, and putting the
1970
+ old value back is exactly how a working sign-in turns into a logout. */
1971
+ let live = []
1972
+ try {
1973
+ live = await this.page.cookies()
1974
+ } catch {
1975
+ live = []
1976
+ }
1977
+ const held = new Set(live.map((c) => `${String(c.domain ?? '').replace(/^\./, '')}|${String(c.path ?? '/')}|${String(c.name ?? '')}`))
1322
1978
  for (const cookie of jar.slice(0, 200)) {
1323
1979
  const domain = String(cookie?.domain ?? '').replace(/^\./, '')
1324
1980
  if (domain === '') continue
1981
+ if (held.has(`${domain}|${String(cookie?.path ?? '/')}|${String(cookie.name)}`)) continue
1325
1982
  try {
1326
1983
  await this.page.setCookie({
1327
1984
  name: String(cookie.name),
@@ -1339,6 +1996,22 @@ export class Sidecar {
1339
1996
  }
1340
1997
 
1341
1998
  /** @returns {Promise<Record<string, unknown>>} what the sidecar is doing. */
1999
+ /**
2000
+ * What the browser holds for one chat, when the caller names it.
2001
+ * @param {string} [session] - chat id.
2002
+ * @returns {{url: string, title: string, viewers: number, alive: boolean, remembered: string}} its page.
2003
+ */
2004
+ async chatState(session = '') {
2005
+ const id = String(session ?? '')
2006
+ /* A chat whose tab is gone still knows where it was: that address is what
2007
+ the pane offers to bring back, so a lost picture is one click, not a
2008
+ question about which site it used to be. */
2009
+ const remembered = this.rememberedTab(id)
2010
+ const page = id === '' ? null : this.pageFor(id)
2011
+ if (page === null || page === undefined) return { url: '', title: '', viewers: 0, alive: false, remembered } // ui-copy-ignore
2012
+ return { url: String(page.url ?? ''), title: String(page.title ?? ''), viewers: page.frames?.size ?? 0, alive: true, remembered } // ui-copy-ignore
2013
+ }
2014
+
1342
2015
  async state() {
1343
2016
  const profiles = (() => {
1344
2017
  try {
@@ -1352,8 +2025,12 @@ export class Sidecar {
1352
2025
  profile: this.profile, // ui-copy-ignore
1353
2026
  headless: this.headless, // ui-copy-ignore
1354
2027
  url: this.page?.url ?? '', // ui-copy-ignore
2028
+ pages: this.pages.size,
1355
2029
  title: this.page?.title ?? '',
1356
2030
  viewers: this.page?.frames.size ?? 0,
2031
+ /* Everyone watching any chat's picture: the pane in the right column is
2032
+ one of these, so 0 means no column is attached right now. */
2033
+ watchers: [...this.pages.values()].reduce((sum, page) => sum + (page.frames?.size ?? 0), 0),
1357
2034
  frames: this.page?.seq ?? 0,
1358
2035
  crashed: this.page?.crashed === true,
1359
2036
  pointer: this.page?.pointer ?? null,