dsh-builtin-browser 0.1.22 → 0.2.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 (38) hide show
  1. package/README.en.md +50 -3
  2. package/README.md +51 -4
  3. package/client.js +298 -0
  4. package/cordis.patch.yml +24 -5
  5. package/docs/README.md +1 -1
  6. package/docs/architecture.md +1 -1
  7. package/docs/tool-reference.md +2 -1
  8. package/lib/browser/runtime.d.ts +18 -1
  9. package/lib/browser/runtime.js +28 -1
  10. package/lib/browser/types.d.ts +33 -0
  11. package/lib/browser-electron/entry.js +115 -0
  12. package/lib/browser-electron/history-store.d.ts +71 -0
  13. package/lib/browser-electron/history-store.js +151 -0
  14. package/lib/browser-electron/host-main.d.ts +1 -0
  15. package/lib/browser-electron/host-main.js +88 -1
  16. package/lib/browser-electron/provider.d.ts +105 -4
  17. package/lib/browser-electron/provider.js +226 -14
  18. package/lib/browser-electron/remote-host.d.ts +29 -6
  19. package/lib/browser-electron/remote-host.js +114 -24
  20. package/lib/browser-electron/settings-store.d.ts +87 -0
  21. package/lib/browser-electron/settings-store.js +160 -0
  22. package/lib/browser-electron/virtual-cursor.d.ts +42 -0
  23. package/lib/browser-electron/virtual-cursor.js +71 -0
  24. package/lib/index.d.ts +16 -2
  25. package/lib/index.js +18 -2
  26. package/lib/tool-browser/index.js +72 -2
  27. package/package.json +20 -6
  28. package/src/browser/runtime.ts +37 -1
  29. package/src/browser/types.ts +31 -0
  30. package/src/browser-electron/entry.ts +135 -0
  31. package/src/browser-electron/history-store.ts +162 -0
  32. package/src/browser-electron/host-main.ts +83 -1
  33. package/src/browser-electron/provider.ts +267 -19
  34. package/src/browser-electron/remote-host.ts +114 -21
  35. package/src/browser-electron/settings-store.ts +198 -0
  36. package/src/browser-electron/virtual-cursor.ts +82 -0
  37. package/src/index.ts +20 -2
  38. package/src/tool-browser/index.ts +71 -1
@@ -11,6 +11,9 @@ import { randomUUID } from 'node:crypto'
11
11
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
12
12
  import { homedir } from 'node:os'
13
13
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
14
+ import { HistoryStore, type VisitedPage } from './history-store.js'
15
+ import type { BrowserSettings } from './settings-store.js'
16
+ import { paintCursor, type CursorAction } from './virtual-cursor.js'
14
17
  import type {
15
18
  BrowserA11yRequest,
16
19
  BrowserA11yResult,
@@ -175,6 +178,13 @@ export interface ElectronBrowserViewHost {
175
178
  * @param handler - called for every user action; must not throw.
176
179
  */
177
180
  onUserAction?(handler: (action: BrowserUserAction) => void): void
181
+ /**
182
+ * Optional: receive notice that the human closed a browser window. Closing the
183
+ * interface ends the session it showed — the next call opens a clean one —
184
+ * while browsing history and login state survive on disk.
185
+ * @param handler - called with the group (session) key; must not throw.
186
+ */
187
+ onViewClosed?(handler: (windowId: string) => void): void
178
188
  }
179
189
 
180
190
  /**
@@ -207,6 +217,12 @@ export interface ElectronViewHandle {
207
217
  * @returns the CDP `result` object.
208
218
  */
209
219
  sendCommand(method: string, params?: Record<string, unknown>): Promise<Record<string, unknown>>
220
+ /**
221
+ * Give the backing view web focus so keyboard input reaches its page.
222
+ * Optional: an adapter that cannot focus a view omits it, and the provider
223
+ * then behaves exactly as before.
224
+ */
225
+ focus?(): Promise<void>
210
226
  }
211
227
 
212
228
  /** One tab inside a session: its view plus a stable id. */
@@ -232,6 +248,28 @@ interface Session {
232
248
  export interface ElectronBrowserProviderConfig {
233
249
  /** Allow navigation only to HTTP(S) URLs; reject anything else. Default true. */
234
250
  readonly httpOnly?: boolean
251
+ /**
252
+ * Persistent browsing history (visited pages), independent of session
253
+ * lifetime. `enabled: false` keeps the browser working but records nothing;
254
+ * the retained limits ride along so a user's choice from the settings panel
255
+ * reaches the store without a second configuration path.
256
+ */
257
+ readonly history?: {
258
+ readonly enabled?: boolean
259
+ readonly maxEntries?: number
260
+ readonly maxAgeDays?: number
261
+ /**
262
+ * Explicit history file path. Default: beside the browser profile
263
+ * (`$DSH_HOME/dsh-builtin-browser-host/history.jsonl`).
264
+ */
265
+ readonly file?: string
266
+ }
267
+ /**
268
+ * Live settings source (the settings panel's document). When present it wins
269
+ * over the static `history` config, so a switch flipped in the UI takes effect
270
+ * without restarting DSH.
271
+ */
272
+ readonly settings?: () => BrowserSettings
235
273
  /** Maximum snapshot elements before truncation. Default 60. */
236
274
  readonly snapshotMaxElements?: number
237
275
  /** Maximum content characters before truncation when no maxChars is given. Default 100_000. */
@@ -254,14 +292,16 @@ export interface CdpNavigateParams {
254
292
  }
255
293
 
256
294
  /**
257
- * CDP method/params for `Input.dispatchMouseEvent` (a click press+release pair).
295
+ * CDP method/params for `Input.dispatchMouseEvent`: a pointer move, or one half
296
+ * of a click's press+release pair. `button`/`clickCount` belong to the press and
297
+ * release halves; a move carries only the position.
258
298
  */
259
299
  export interface CdpMouseParams {
260
- readonly type: 'mousePressed' | 'mouseReleased'
300
+ readonly type: 'mouseMoved' | 'mousePressed' | 'mouseReleased'
261
301
  readonly x: number
262
302
  readonly y: number
263
- readonly button: 'left'
264
- readonly clickCount: number
303
+ readonly button?: 'left'
304
+ readonly clickCount?: number
265
305
  }
266
306
 
267
307
  /** CDP method/params for `Input.insertText`. */
@@ -357,6 +397,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
357
397
  private readonly snapshotMaxElements: number
358
398
  private readonly contentMaxChars: number
359
399
  private readonly downloadDir: string | undefined
400
+ /**
401
+ * Persistent browsing history, or `undefined` when the user turned it off.
402
+ * Sessions come and go; this record outlives all of them.
403
+ */
404
+ private readonly historyStore: HistoryStore | undefined
405
+ /** Live settings source; absent when nothing owns a settings document. */
406
+ private readonly settingsSource: (() => BrowserSettings) | undefined
360
407
 
361
408
  constructor(
362
409
  private readonly host: ElectronBrowserViewHost,
@@ -371,9 +418,21 @@ export class ElectronBrowserProvider implements BrowserProvider {
371
418
  // sandbox does not have to arbitrate. The localized name is probed so a
372
419
  // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
373
420
  this.downloadDir = config.downloadDir ?? defaultDownloadDir()
421
+ this.settingsSource = config.settings
422
+ // Browsing history follows the browser profile's persistence rules (close
423
+ // the interface, lose nothing), and can be switched off from settings.
424
+ this.historyStore = config.history?.enabled === false
425
+ ? undefined
426
+ : new HistoryStore(config.history?.file, {
427
+ ...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
428
+ ...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
429
+ })
374
430
  // Route toolbar (host UI) actions into the session model: the human and
375
431
  // the agent then always drive the same tabs, history, and navigation.
376
432
  this.host.onUserAction?.(action => { void this.handleUserAction(action) })
433
+ // A window the human closed ends the session it showed: the next call opens a
434
+ // clean one instead of driving a page nobody can see.
435
+ this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId) })
377
436
  }
378
437
 
379
438
  /**
@@ -548,6 +607,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
548
607
  // NEXT observation sees, not whether this action happened).
549
608
  this.record(s, 'navigate', { url }, true)
550
609
  await this.settleDocument(handle, before, signal)
610
+ this.recordVisit(s, handle)
551
611
  this.showActive(s)
552
612
  } catch (error) {
553
613
  if (!(error instanceof BrowserError && (error as { code?: string }).code === 'BROWSER_NAVIGATION_BLOCKED')) {
@@ -664,6 +724,26 @@ export class ElectronBrowserProvider implements BrowserProvider {
664
724
  }
665
725
  }
666
726
 
727
+ /**
728
+ * Give the view web focus before synthesizing keyboard input.
729
+ *
730
+ * A renderer only delivers key events to the view that holds web focus. A
731
+ * view that was created but never clicked holds none — and `Input` events
732
+ * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
733
+ * session vanished inside the renderer while the command still answered
734
+ * success. Hosts that cannot focus a view (a test double, a different shell
735
+ * adapter) simply have no `focus`, and nothing changes for them.
736
+ */
737
+ private async focusView(handle: ElectronViewHandle): Promise<void> {
738
+ if (handle.focus === undefined) return
739
+ try {
740
+ await handle.focus()
741
+ } catch {
742
+ // Best-effort: a view that refuses focus must not fail the caller's key
743
+ // press; the dispatch below reports a real channel failure itself.
744
+ }
745
+ }
746
+
667
747
  /** Execute JS in the active tab's page context. */
668
748
  async execute(session: BrowserSessionId, request: BrowserExecuteRequest, signal?: AbortSignal): Promise<BrowserExecuteResult> {
669
749
  const s = this.session(session)
@@ -1196,23 +1276,54 @@ export class ElectronBrowserProvider implements BrowserProvider {
1196
1276
  // Input.* is only delivered to a view with a current display surface; the
1197
1277
  // barrier also re-presents after a navigation replaced the renderer.
1198
1278
  await this.present(s, signal)
1199
- const press = (type: 'mousePressed' | 'mouseReleased'): Promise<Record<string, unknown>> =>
1200
- handle.sendCommand('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 } satisfies CdpMouseParams)
1201
1279
  const timeoutMs = 30_000
1280
+ const send = (params: Record<string, unknown>, label: string): Promise<unknown> =>
1281
+ withTimeout(
1282
+ handle.sendCommand('Input.dispatchMouseEvent', params),
1283
+ timeoutMs,
1284
+ signal,
1285
+ `browser: click ${label} timed out after ${timeoutMs}ms`,
1286
+ )
1287
+ // Chromium routes a synthesized `mousePressed` to the widget's *current*
1288
+ // hover target rather than hit-testing the coordinates, and a real pointer
1289
+ // click is always preceded by movement. A view that has never received a
1290
+ // mouse event has no hover target yet, so the press of the FIRST click on a
1291
+ // fresh tab went nowhere while CDP still answered success; the second click
1292
+ // landed because the first had quietly established the target. Move the
1293
+ // pointer first so a click is self-contained.
1202
1294
  try {
1203
- await withTimeout(press('mousePressed'), timeoutMs, signal, `browser: click press timed out after ${timeoutMs}ms`)
1295
+ await send({ type: 'mouseMoved', x, y } satisfies CdpMouseParams, 'move')
1296
+ } catch (error) {
1297
+ throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1298
+ }
1299
+ // Paint before the press so the ripple is already on screen when the click
1300
+ // lands — the cursor exists to show the human WHERE the agent acts.
1301
+ this.showCursor(handle, x, y, 'click')
1302
+ const release = (): void => {
1303
+ void handle
1304
+ .sendCommand('Input.dispatchMouseEvent', {
1305
+ type: 'mouseReleased',
1306
+ x,
1307
+ y,
1308
+ button: 'left',
1309
+ clickCount: 1,
1310
+ } satisfies CdpMouseParams)
1311
+ .catch(() => {})
1312
+ }
1313
+ try {
1314
+ await send({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, 'press')
1204
1315
  } catch (error) {
1205
1316
  // The press may still land late; release best-effort so the button is
1206
1317
  // never left in a stuck pressed state.
1207
- void press('mouseReleased').catch(() => {})
1318
+ release()
1208
1319
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1209
1320
  }
1210
1321
  try {
1211
- await withTimeout(press('mouseReleased'), timeoutMs, signal, `browser: click release timed out after ${timeoutMs}ms`)
1322
+ await send({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, 'release')
1212
1323
  } catch (error) {
1213
1324
  // The press already landed; retry the release so the button is not
1214
1325
  // left pressed before surfacing the failure.
1215
- void press('mouseReleased').catch(() => {})
1326
+ release()
1216
1327
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error })
1217
1328
  }
1218
1329
  this.record(s, 'click', 'target' in request ? { target: request.target } : { x, y }, true)
@@ -1237,7 +1348,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
1237
1348
  el.focus()
1238
1349
  return { ok: true }
1239
1350
  `)
1240
- await this.runTargetScript(this.activeTab(s), script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
1351
+ const locateTab = this.activeTab(s)
1352
+ const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
1353
+ const cursorPoint = scriptPoint(out)
1354
+ if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click')
1241
1355
  }
1242
1356
  const text = 'text' in request ? request.text : ''
1243
1357
  const timeoutMs = 30_000
@@ -1327,6 +1441,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1327
1441
  )
1328
1442
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true)
1329
1443
  await this.settleDocument(handle, before, signal)
1444
+ this.recordVisit(s, handle)
1330
1445
  this.showActive(s)
1331
1446
  }
1332
1447
 
@@ -1359,6 +1474,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1359
1474
  )
1360
1475
  this.record(s, 'reload', {}, true)
1361
1476
  await this.settleDocument(handle, before, signal)
1477
+ this.recordVisit(s, handle)
1362
1478
  this.showActive(s)
1363
1479
  }
1364
1480
 
@@ -1375,6 +1491,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1375
1491
  const timeoutMs = 15_000
1376
1492
  // Input.dispatchKeyEvent is subject to the same display-surface rule.
1377
1493
  await this.present(s, signal)
1494
+ await this.focusView(handle)
1378
1495
  const release = (): Promise<Record<string, unknown>> =>
1379
1496
  handle.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', ...params })
1380
1497
  try {
@@ -1552,10 +1669,16 @@ export class ElectronBrowserProvider implements BrowserProvider {
1552
1669
  * (polling until it appears or the budget runs out) and then runs `body`
1553
1670
  * with `el` in scope. Shared by the target-based tools: click/type (②),
1554
1671
  * setValue/check/select/clear/getValue (③), and the scrape item wait.
1672
+ * A selector that fails to PARSE is reported immediately instead of being
1673
+ * polled until the budget expires — see the comment in `match`.
1555
1674
  */
1556
1675
  private buildTargetScript(spec: BrowserElementTarget, timeoutMs: number, body: string): string {
1676
+ // `by` defaults to css in-page; report the strategy that WAS used, or a
1677
+ // miss says only `{"value":"Learn more"}` and the caller cannot tell that a
1678
+ // plain label was queried as a CSS selector.
1679
+ const resolvedSpec = { ...spec, by: spec.by ?? 'css' }
1557
1680
  return `(async () => {
1558
- const spec = ${JSON.stringify(spec)}
1681
+ const spec = ${JSON.stringify(resolvedSpec)}
1559
1682
  const timeoutMs = ${String(timeoutMs)}
1560
1683
  const sleep = ms => new Promise(r => setTimeout(r, ms))
1561
1684
  const isVisible = el => el instanceof HTMLElement && (el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0)
@@ -1567,7 +1690,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
1567
1690
  const index = typeof spec.index === 'number' ? spec.index : 0
1568
1691
  let els = []
1569
1692
  if (by === 'css') {
1570
- try { els = Array.from(document.querySelectorAll(value)) } catch { return null }
1693
+ // A selector that does not PARSE can never match on a later poll, so
1694
+ // it must not look like a miss: the old "return null" kept the caller
1695
+ // polling until the whole budget was gone, and "element not found
1696
+ // after 10s" reads like a slow page while the real cause is a
1697
+ // malformed selector (a label or plain text passed where one belongs).
1698
+ try { els = Array.from(document.querySelectorAll(value)) }
1699
+ catch (error) { throw new Error('invalid CSS selector ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1571
1700
  } else if (by === 'xpath') {
1572
1701
  try {
1573
1702
  const snap = document.evaluate(value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
@@ -1575,7 +1704,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1575
1704
  const n = snap.snapshotItem(i)
1576
1705
  if (n instanceof Element) els.push(n)
1577
1706
  }
1578
- } catch { return null }
1707
+ } catch (error) { throw new Error('invalid XPath ' + JSON.stringify(value) + ' (' + String((error && error.message) || error) + ')') }
1579
1708
  } else {
1580
1709
  const all = Array.from(document.querySelectorAll('body *'))
1581
1710
  const exact = all.filter(el => isVisible(el) && ownText(el) === value)
@@ -1589,12 +1718,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
1589
1718
  const deadline = Date.now() + timeoutMs
1590
1719
  let el = null
1591
1720
  for (;;) {
1592
- el = match()
1721
+ // Only a parse error escapes the poll: see the comment in match() above.
1722
+ try { el = match() } catch (error) { return { ok: false, error: String((error && error.message) || error) } }
1593
1723
  if (el !== null) break
1594
- if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) }
1724
+ if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1595
1725
  await sleep(100)
1596
1726
  }
1727
+ const __result = await (async () => {
1597
1728
  ${body}
1729
+ })()
1730
+ // Report the operated element's viewport center next to the script's own
1731
+ // verdict: the synthetic cursor (and any future feedback) needs the point,
1732
+ // and no individual body should have to compute it a second time. Kept
1733
+ // under a distinct key so a body that already returns x/y keeps them.
1734
+ const __rect = el.getBoundingClientRect()
1735
+ return {
1736
+ ...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
1737
+ __point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
1738
+ }
1598
1739
  })()`
1599
1740
  }
1600
1741
 
@@ -1611,9 +1752,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
1611
1752
  errorCode: string,
1612
1753
  errorLabel: string,
1613
1754
  ): Promise<Record<string, unknown>> {
1755
+ // The script polls for its whole `timeoutMs` and only then answers, so an
1756
+ // outer budget of the same size fires at the same instant and wins: the
1757
+ // caller saw `click timed out after 10000ms` while the page had already
1758
+ // decided WHY (selector not found / malformed). Give the answer a chance to
1759
+ // get back before the generic timeout replaces it.
1614
1760
  const result = await withTimeout(
1615
1761
  handleSendEvaluate(tab.handle, script),
1616
- timeoutMs,
1762
+ timeoutMs + TARGET_SCRIPT_GRACE_MS,
1617
1763
  signal,
1618
1764
  `${errorLabel} timed out after ${timeoutMs}ms`,
1619
1765
  () => terminatePage(tab.handle),
@@ -1667,6 +1813,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1667
1813
  `
1668
1814
  const script = this.buildTargetScript(request.target, timeoutMs, body)
1669
1815
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue')
1816
+ const cursorPoint = scriptPoint(out)
1817
+ if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
1670
1818
  this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method as string })
1671
1819
  return { method: out.method as string, value: out.value as string }
1672
1820
  }
@@ -1693,6 +1841,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1693
1841
  `
1694
1842
  const script = this.buildTargetScript(request.target, timeoutMs, body)
1695
1843
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check')
1844
+ const cursorPoint = scriptPoint(out)
1845
+ if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
1696
1846
  this.record(s, 'check', { target: request.target, checked: want }, true)
1697
1847
  return { checked: out.checked === true }
1698
1848
  }
@@ -1725,6 +1875,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1725
1875
  `
1726
1876
  const script = this.buildTargetScript(request.target, timeoutMs, body)
1727
1877
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select')
1878
+ const cursorPoint = scriptPoint(out)
1879
+ if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
1728
1880
  this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true)
1729
1881
  return { value: out.value as string, text: out.text as string }
1730
1882
  }
@@ -1758,7 +1910,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1758
1910
  return { ok: true }
1759
1911
  `
1760
1912
  const script = this.buildTargetScript(request.target, timeoutMs, body)
1761
- await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
1913
+ const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear')
1914
+ const cursorPoint = scriptPoint(out)
1915
+ if (cursorPoint !== undefined) this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click')
1762
1916
  this.record(s, 'clear', { target: request.target }, true)
1763
1917
  return { cleared: true }
1764
1918
  }
@@ -1813,7 +1967,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1813
1967
  const deadline = Date.now() + timeoutMs
1814
1968
  let items = []
1815
1969
  for (;;) {
1816
- try { items = Array.from(document.querySelectorAll(itemSel)) } catch (e) { return { ok: false, error: String(e) } }
1970
+ // Immediate, like match() above: an unparseable selector can never match.
1971
+ try { items = Array.from(document.querySelectorAll(itemSel)) }
1972
+ catch (error) { return { ok: false, error: 'invalid CSS selector ' + JSON.stringify(itemSel) + ' (' + String((error && error.message) || error) + ')' } }
1817
1973
  if (items.length > 0) break
1818
1974
  if (Date.now() >= deadline) return { ok: false, error: 'no elements matched: ' + itemSel }
1819
1975
  await sleep(100)
@@ -2083,6 +2239,60 @@ export class ElectronBrowserProvider implements BrowserProvider {
2083
2239
  return this.session(session).history
2084
2240
  }
2085
2241
 
2242
+ /**
2243
+ * Record one page visit in the persistent browsing history. Fire-and-forget by
2244
+ * design: a visit must never delay or fail the navigation that produced it,
2245
+ * and the title is only worth reading once the document has settled.
2246
+ * @param s - the session that drove the visit.
2247
+ * @param handle - the view whose document just loaded.
2248
+ */
2249
+ private recordVisit(s: Session, handle: ElectronViewHandle): void {
2250
+ const store = this.historyStore
2251
+ if (store === undefined) return
2252
+ // The settings panel can switch recording off at runtime; the static config
2253
+ // only supplies the startup default.
2254
+ if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
2255
+ void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
2256
+ .then(result => {
2257
+ if (!result.ok || typeof result.value !== 'string') return
2258
+ const [url = '', title = ''] = result.value.split('\u0000')
2259
+ // Internal documents are not visits: an untouched browser is not history.
2260
+ if (url === '' || url.startsWith('about:') || url.startsWith('devtools:')) return
2261
+ store.append({
2262
+ at: Date.now(),
2263
+ url,
2264
+ ...title.trim() !== '' ? { title: title.trim() } : {},
2265
+ ...s.label !== undefined ? { session: s.label } : {},
2266
+ })
2267
+ store.prune()
2268
+ })
2269
+ .catch(() => { /* best-effort: history is never worth surfacing an error */ })
2270
+ }
2271
+
2272
+ /**
2273
+ * List persisted visits, newest first — what `browser_visited` reads back.
2274
+ * @param options - result cap and an optional hostname filter.
2275
+ * @returns the matching visits, or an empty list when history is disabled.
2276
+ */
2277
+ visited(options: { readonly limit?: number; readonly domain?: string } = {}): readonly VisitedPage[] {
2278
+ return this.historyStore?.list(options) ?? []
2279
+ }
2280
+
2281
+ /**
2282
+ * Show the synthetic pointer on a view, unless the user switched it off. The
2283
+ * cursor is the "the agent has taken over this tab" signal, so it is painted
2284
+ * for every operation that has a landing point — including DOM-level ones that
2285
+ * move no real pointer.
2286
+ * @param handle - the view to paint into.
2287
+ * @param x - viewport x in CSS pixels.
2288
+ * @param y - viewport y in CSS pixels.
2289
+ * @param action - click pulses a ripple; move only relocates.
2290
+ */
2291
+ private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction): void {
2292
+ if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
2293
+ paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression))
2294
+ }
2295
+
2086
2296
  /**
2087
2297
  * Replay one recorded operation by sequence number. Navigate/click/type are
2088
2298
  * re-issued against the current page; execute re-runs its script. The
@@ -2195,6 +2405,29 @@ export class ElectronBrowserProvider implements BrowserProvider {
2195
2405
  return s.tabs.find(tab => tab.handle.id === viewId)
2196
2406
  }
2197
2407
 
2408
+ /**
2409
+ * Whether a session is still live. The human closing a window ends its session
2410
+ * (see {@link handleViewClosed}), so callers that cache ids must ask first.
2411
+ * @param session - the session id to test.
2412
+ */
2413
+ exists(session: BrowserSessionId): boolean {
2414
+ return this.sessions.has(session)
2415
+ }
2416
+
2417
+ /**
2418
+ * The human closed a browser window: the session that window showed is over.
2419
+ * Ending it here is what makes the next call a clean start rather than a
2420
+ * resurrection of an invisible window. Browsing history and login state live
2421
+ * on disk, so nothing the human cares about is lost with it.
2422
+ * @param windowId - the group key the host reported, which is the session id.
2423
+ */
2424
+ private async handleViewClosed(windowId: string): Promise<void> {
2425
+ if (typeof windowId !== 'string' || windowId === '') return
2426
+ const s = this.sessions.get(windowId as BrowserSessionId)
2427
+ if (s === undefined) return
2428
+ await this.close(s.id).catch(() => { /* the window is already gone */ })
2429
+ }
2430
+
2198
2431
  /**
2199
2432
  * Route a user-initiated action from the host's UI into the session model.
2200
2433
  * Fire-and-forget by design: a user action failing (e.g. an unreachable
@@ -2295,6 +2528,13 @@ const SETTLE_GRACE_MS = 150
2295
2528
  /** Poll interval for the document-settle loop. */
2296
2529
  const SETTLE_POLL_MS = 25
2297
2530
 
2531
+ /**
2532
+ * Extra time the transport gets over an in-page locate script's own budget: the
2533
+ * script polls for `timeoutMs` and answers only afterwards, so the outer wait
2534
+ * must outlast it or its generic timeout hides the in-page reason.
2535
+ */
2536
+ const TARGET_SCRIPT_GRACE_MS = 2_000
2537
+
2298
2538
  /** `tab:<uuid>` ↔ `<uuid>`: ids accept either form (see locateTab). */
2299
2539
  function stripTabPrefix(id: string): string {
2300
2540
  return id.startsWith('tab:') ? id.slice(4) : id
@@ -2393,6 +2633,14 @@ function terminatePage(handle: ElectronViewHandle): void {
2393
2633
  * @param expression - the JS expression.
2394
2634
  * @param signal - optional abort signal; a fired signal rejects the call.
2395
2635
  */
2636
+ /** Viewport point a target script reported, when it reported one. */
2637
+ function scriptPoint(out: Record<string, unknown> | undefined): { x: number; y: number } | undefined {
2638
+ const point = out?.__point as { x?: unknown; y?: unknown } | undefined
2639
+ const x = Number(point?.x)
2640
+ const y = Number(point?.y)
2641
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined
2642
+ }
2643
+
2396
2644
  async function handleSendEvaluate(
2397
2645
  handle: ElectronViewHandle,
2398
2646
  expression: string,