dsh-browser-plus 0.5.0 → 0.5.1

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 (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -47,8 +47,13 @@ import type {
47
47
  BrowserTaskUpdate,
48
48
  BrowserUploadFileRequest,
49
49
  BrowserUploadFileResult,
50
+ BrowserPdfRequest,
51
+ BrowserPdfResult,
52
+ BrowserHighlightRequest,
53
+ BrowserHighlightResult,
50
54
  BrowserWaitForRequest,
51
55
  BrowserWaitForResult,
56
+ BrowserTaskTodo,
52
57
  ExportedCookie,
53
58
  } from '../browser/types.ts'
54
59
  import { BrowserError } from '../browser/types.ts'
@@ -188,6 +193,8 @@ export interface ElectronBrowserViewHost {
188
193
  getTask?(key: string): Promise<BrowserTaskInfo | undefined>
189
194
  /** Apply a task status/control update to the visible workspace. */
190
195
  updateTask?(key: string, update: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>
196
+ /** Mirror one task's Agent todo list into the visible workspace (floating orb). */
197
+ setTaskTodos?(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>
191
198
  }
192
199
 
193
200
  /**
@@ -231,10 +238,28 @@ export interface ElectronViewHandle {
231
238
  * tokenless copy as a fallback.
232
239
  */
233
240
  reinstallChrome?(): Promise<void>
241
+ /**
242
+ * Print this view to a PDF and return it base64-encoded. Optional: a host
243
+ * with no native print path omits it, and the provider falls back to CDP.
244
+ *
245
+ * Electron's debugger does NOT implement CDP `Page.printToPDF` (it answers
246
+ * "'Page.printToPDF' wasn't found"), so the PDF path has to go through the
247
+ * host's own `webContents.printToPDF`.
248
+ * @param options - Electron PrintToPDFOptions (pageSize in microns).
249
+ */
250
+ printToPdf?(options: Record<string, unknown>): Promise<{ readonly base64: string }>
251
+ /**
252
+ * Raise this view's window (show + restore + focus). Optional: a host that
253
+ * owns no window omits it. Distinct from the provider's own show/switch
254
+ * path, which deliberately leaves a human-selected visible task alone.
255
+ */
256
+ focusWindow?(): Promise<void>
234
257
  }
235
258
 
236
259
  /** Internal selector and fingerprint captured for one snapshot element. */
237
260
  interface SnapshotTarget {
261
+ /** iframe index when the target lives inside a frame. */
262
+ readonly frame?: number
238
263
  readonly path: string
239
264
  readonly fingerprint: string
240
265
  }
@@ -377,6 +402,12 @@ export interface CdpEvaluateParams {
377
402
 
378
403
  /** CDP method for a full-page screenshot capture. */
379
404
  export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot'
405
+ /** CDP method that renders the current document to PDF. */
406
+ export const CDP_PAGE_PRINT_TO_PDF = 'Page.printToPDF'
407
+ /** CDP methods that draw the DevTools highlight box without touching the DOM. */
408
+ export const CDP_OVERLAY_ENABLE = 'Overlay.enable'
409
+ export const CDP_OVERLAY_HIGHLIGHT_NODE = 'Overlay.highlightNode'
410
+ export const CDP_OVERLAY_HIDE_HIGHLIGHT = 'Overlay.hideHighlight'
380
411
  /** CDP method for runtime evaluation (the execute path). */
381
412
  export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate'
382
413
 
@@ -530,7 +561,6 @@ export const CDP_PAGE_RELOAD = 'Page.reload'
530
561
  export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading'
531
562
 
532
563
  /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
533
- const SNAPSHOT_LABEL_MAX = 120
534
564
 
535
565
  /** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
536
566
  const INPUT_DISPATCH_TIMEOUT_MS = 15_000
@@ -686,6 +716,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
686
716
  /** Stable task-key index so callers can recover a session after tool-layer state loss. */
687
717
  private readonly sessionsByTask = new Map<string, BrowserSessionId>()
688
718
  private readonly taskStates = new Map<string, LocalTaskState>()
719
+ /**
720
+ * The Agent's todo list per task, kept here as well as in the host.
721
+ *
722
+ * The host's copy is in memory and dies with the host process, and the bridge
723
+ * only pushes on a todo change — so without this cache a host restart left the
724
+ * floating orb with no plan until the Agent happened to write its list again.
725
+ */
726
+ private readonly taskTodos = new Map<string, readonly BrowserTaskTodo[]>()
689
727
  private readonly httpOnly: boolean
690
728
  private readonly snapshotMaxElements: number
691
729
  private readonly contentMaxChars: number
@@ -802,12 +840,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
802
840
  const labelable = active as { label?(label: string): Promise<void> } | undefined
803
841
  if (typeof labelable?.label === 'function') await labelable.label(taskLabel).catch(() => undefined)
804
842
  }
843
+ // 宿主重启后它内存里那份计划是空的,而这是**已存在会话**唯一会走到的入口
844
+ // (ensureWindowVisible 也补推一次)—— 不推的话球上会一直空着。
845
+ const cached = this.taskTodos.get(taskKey)
846
+ if (cached !== undefined && typeof this.host.setTaskTodos === 'function') {
847
+ void this.host.setTaskTodos(taskKey, cached).catch(() => undefined)
848
+ }
805
849
  return existing.id
806
850
  }
807
851
  const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel)
808
852
  const id = `browser:${randomUUID()}`
809
853
  this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 })
810
854
  this.sessionsByTask.set(taskKey, id)
855
+ // A fresh view means a host that may never have seen this task's plan (a host
856
+ // restart drops the host's copy), so replay what the Agent last wrote.
857
+ const cachedTodos = this.taskTodos.get(taskKey)
858
+ if (cachedTodos !== undefined && typeof this.host.setTaskTodos === 'function') {
859
+ void this.host.setTaskTodos(taskKey, cachedTodos).catch(() => undefined)
860
+ }
811
861
  if (!this.taskStates.has(taskKey)) {
812
862
  this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() })
813
863
  }
@@ -1133,7 +1183,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1133
1183
  if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
1134
1184
  return tag
1135
1185
  }
1136
- const pathOf = (el) => {
1186
+ const pathOf = (el, doc) => {
1137
1187
  if (el.id) return '#' + CSS.escape(el.id)
1138
1188
  const parts = []
1139
1189
  let node = el
@@ -1145,7 +1195,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1145
1195
  if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
1146
1196
  }
1147
1197
  parts.unshift(part)
1148
- if (node === document.body) break
1198
+ if (node === doc.body) break
1149
1199
  node = parent
1150
1200
  }
1151
1201
  return parts.join(' > ')
@@ -1160,42 +1210,65 @@ export class ElectronBrowserProvider implements BrowserProvider {
1160
1210
  ].join('\u001f')
1161
1211
  const url = location.href
1162
1212
  const title = document.title || undefined
1163
- const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
1164
1213
  const out = []
1165
1214
  let capped = false
1166
- for (const el of els) {
1167
- if (el.closest('[data-dsh-browser-chrome]')) continue
1168
- const r = el.getBoundingClientRect()
1169
- const cs = getComputedStyle(el)
1170
- if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
1171
- const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
1172
- : el.tagName === 'TEXTAREA' ? 'textarea'
1173
- : el.tagName === 'SELECT' ? 'select'
1174
- : el.tagName === 'BUTTON' ? 'button'
1175
- : el.tagName === 'A' ? 'link' : 'other'
1176
- const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').toString().replace(/\\s+/g, ' ').trim().slice(0, ${String(SNAPSHOT_LABEL_MAX)})
1177
- if (!label && kind !== 'link') continue
1178
- // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
1179
- if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
1180
- if (out.length >= cap) { capped = true; break }
1181
- out.push({
1182
- ref: out.length + 1,
1183
- kind,
1184
- label,
1185
- selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
1186
- loc: locatorOf(el),
1187
- path: pathOf(el),
1188
- fingerprint: fingerprintOf(el),
1189
- x: Math.round(r.x + r.width / 2),
1190
- y: Math.round(r.y + r.height / 2),
1191
- })
1215
+ // 同一套判定,用在顶层文档和每个同源子框架上;dx/dy 把框架内的坐标换算成页面坐标。
1216
+ const collect = (doc, frameIndex, dx, dy) => {
1217
+ const els = [...doc.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [role="link"], [contenteditable="true"]')]
1218
+ for (const el of els) {
1219
+ if (el.closest('[data-dsh-browser-chrome]')) continue
1220
+ const r = el.getBoundingClientRect()
1221
+ const cs = getComputedStyle(el)
1222
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
1223
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'text'))
1224
+ : el.tagName === 'TEXTAREA' ? 'textarea'
1225
+ : el.tagName === 'SELECT' ? 'select'
1226
+ : el.tagName === 'BUTTON' ? 'button'
1227
+ : el.tagName === 'A' ? 'link' : 'other'
1228
+ const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').replace(/\s+/g, ' ').trim().slice(0, 120)
1229
+ if (!label && kind !== 'link') continue
1230
+ // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
1231
+ if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
1232
+ if (out.length >= cap) { capped = true; return }
1233
+ out.push({
1234
+ ref: out.length + 1,
1235
+ kind,
1236
+ label,
1237
+ ...(frameIndex === undefined ? {} : { frame: frameIndex }),
1238
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
1239
+ loc: locatorOf(el),
1240
+ path: pathOf(el, doc),
1241
+ fingerprint: fingerprintOf(el),
1242
+ x: Math.round(r.x + r.width / 2 + dx),
1243
+ y: Math.round(r.y + r.height / 2 + dy),
1244
+ })
1245
+ }
1246
+ }
1247
+ collect(document, undefined, 0, 0)
1248
+ // 子框架:同源的走进去(元素带 frame 序号、坐标加上框架自己的矩形);
1249
+ // 跨源的 contentDocument 读不到,就只记一条 readable:false —— 不假装能读。
1250
+ const frames = []
1251
+ const frameEls = [...document.querySelectorAll('iframe')]
1252
+ for (let i = 0; i < frameEls.length; i++) {
1253
+ const frame = frameEls[i]
1254
+ const src = frame.getAttribute('src') || ''
1255
+ let inner = null
1256
+ try { inner = frame.contentDocument } catch (error) { inner = null }
1257
+ if (inner === null) {
1258
+ frames.push({ index: i, url: src, readable: false })
1259
+ continue
1260
+ }
1261
+ const fr = frame.getBoundingClientRect()
1262
+ frames.push({ index: i, url: inner.location.href || src, readable: true })
1263
+ if (out.length >= cap) { capped = true; continue }
1264
+ collect(inner, i, fr.left, fr.top)
1192
1265
  }
1193
1266
  const challenge = ${CHALLENGE_DETECT_EXPRESSION}
1194
1267
  const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
1195
1268
  const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
1196
1269
  // Only an early break means truncation: exactly cap candidates is a
1197
1270
  // complete inventory, not a truncated one.
1198
- return { url, title, elements: out, truncated: capped, challenge, userControlling }
1271
+ return { url, title, elements: out, truncated: capped, frames, challenge, userControlling }
1199
1272
  })()`
1200
1273
  // Same hang guard as execute: a renderer that has not committed after
1201
1274
  // navigate would otherwise block snapshot forever.
@@ -1235,7 +1308,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
1235
1308
  const snapshotId = `snapshot:${randomUUID()}`
1236
1309
  const targets = new Map<number, SnapshotTarget>()
1237
1310
  for (const element of value.elements) {
1238
- targets.set(element.ref, { path: element.path, fingerprint: element.fingerprint })
1311
+ targets.set(element.ref, {
1312
+ path: element.path,
1313
+ fingerprint: element.fingerprint,
1314
+ ...element.frame !== undefined ? { frame: element.frame } : {},
1315
+ })
1239
1316
  }
1240
1317
  tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets })
1241
1318
  while (tab.snapshots.size > 10) {
@@ -1248,6 +1325,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1248
1325
  url: value.url,
1249
1326
  ...value.title !== undefined ? { title: value.title } : {},
1250
1327
  elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
1328
+ ...value.frames !== undefined ? { frames: value.frames } : {},
1251
1329
  truncated: value.truncated,
1252
1330
  ...value.challenge !== undefined ? { challenge: value.challenge } : {},
1253
1331
  ...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
@@ -1528,27 +1606,55 @@ export class ElectronBrowserProvider implements BrowserProvider {
1528
1606
  return this.waitForElementTab(s, this.activeTab(s), request, signal)
1529
1607
  }
1530
1608
 
1531
- /** Poll one tab until the selector matches. */
1609
+ /** Poll one tab until the selector and/or text reaches the requested state. */
1532
1610
  private async waitForElementTab(s: Session, tab: Tab, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult> {
1533
1611
  const { handle } = tab
1534
1612
  signal?.throwIfAborted()
1535
1613
  const timeoutMs = request.timeoutMs ?? 15_000
1536
- const visible = request.visible !== false
1537
- const selector = request.selector
1614
+ const selector = request.selector ?? ''
1615
+ const wanted = request.text
1616
+ const state: 'visible' | 'attached' | 'hidden' | 'detached' = request.state ?? (request.visible === false ? 'attached' : 'visible')
1617
+ if (selector === '' && wanted === undefined) {
1618
+ throw new BrowserError('browser: waitForElement needs a selector, some text, or both', 'BROWSER_WAIT_INVALID')
1619
+ }
1620
+ if (selector === '' && (state === 'hidden' || state === 'detached')) {
1621
+ throw new BrowserError(`browser: state "${state}" needs a selector to watch disappear`, 'BROWSER_WAIT_INVALID')
1622
+ }
1538
1623
  const script = `(() => {
1624
+ const selector = ${JSON.stringify(selector)}
1625
+ const wanted = ${JSON.stringify(wanted ?? null)}
1626
+ const state = ${JSON.stringify(state)}
1539
1627
  let el = null
1540
- try { el = document.querySelector(${JSON.stringify(selector)}) } catch (e) { return { error: String(e) } }
1541
- if (!el) return null
1542
- if (${visible}) {
1543
- const r = el.getBoundingClientRect()
1544
- const cs = getComputedStyle(el)
1545
- if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') return null
1628
+ if (selector !== '') {
1629
+ try { el = document.querySelector(selector) } catch (e) { return { error: String(e) } }
1546
1630
  }
1631
+ const visibleNow = node => {
1632
+ if (node === null) return false
1633
+ const r = node.getBoundingClientRect()
1634
+ if (r.width < 4 || r.height < 4) return false
1635
+ const cs = getComputedStyle(node)
1636
+ return cs.visibility !== 'hidden' && cs.display !== 'none'
1637
+ }
1638
+ const textOf = node => (node === null ? '' : (node.textContent || '')).replace(/\\s+/g, ' ').trim()
1639
+ const attached = el !== null
1640
+ const visible = visibleNow(el)
1641
+ let met
1642
+ if (selector === '') {
1643
+ // 只等文字时没有元素可量:文字在不在,本身就是那个状态。
1644
+ // (以前这里仍按 visibleNow(null) === false 判定,于是默认的 visible 永远不成立。)
1645
+ const present = textOf(document.body).includes(wanted)
1646
+ met = state === 'hidden' || state === 'detached' ? !present : present
1647
+ } else {
1648
+ met = state === 'attached' ? attached : state === 'visible' ? visible : state === 'hidden' ? !visible : state === 'detached' ? !attached : false
1649
+ if (met && wanted !== null) met = textOf(el).includes(wanted)
1650
+ }
1651
+ if (!met) return null
1547
1652
  return {
1548
1653
  found: true,
1549
- selector: ${JSON.stringify(selector)},
1550
- tag: el.tagName.toLowerCase(),
1551
- text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
1654
+ state,
1655
+ selector,
1656
+ tag: attached ? el.tagName.toLowerCase() : '',
1657
+ text: (wanted !== null && !attached ? wanted : textOf(selector === '' ? document.body : el)).slice(0, 200),
1552
1658
  }
1553
1659
  })()`
1554
1660
  const deadline = Date.now() + timeoutMs
@@ -1561,8 +1667,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1561
1667
  } else {
1562
1668
  const value = result.value as { found?: boolean; tag?: string; text?: string; error?: string } | null
1563
1669
  if (value?.found === true && typeof value.tag === 'string') {
1564
- this.record(s, 'waitForElement', { selector, timeoutMs, visible }, true, { result: value.tag })
1565
- return { found: true, selector, tag: value.tag, text: value.text ?? '' }
1670
+ this.record(s, 'waitForElement', { selector, text: wanted, state, timeoutMs }, true, { result: value.tag ?? '' })
1671
+ return { found: true, state, selector, tag: value.tag, text: value.text ?? '' }
1566
1672
  }
1567
1673
  if (value?.error !== undefined) {
1568
1674
  // A malformed selector can never start matching, so fail now instead of
@@ -1572,7 +1678,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
1572
1678
  }
1573
1679
  await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))))
1574
1680
  }
1575
- throw new BrowserError(`browser: element "${selector}" did not appear within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT')
1681
+ const what = selector === '' ? `text ${JSON.stringify(wanted)}` : `"${selector}"`
1682
+ throw new BrowserError(`browser: ${what} did not reach state "${state}" within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT')
1576
1683
  }
1577
1684
 
1578
1685
  /** Type into the focused element. */
@@ -2085,6 +2192,111 @@ export class ElectronBrowserProvider implements BrowserProvider {
2085
2192
  }
2086
2193
 
2087
2194
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
2195
+ /**
2196
+ * Draw the DevTools highlight box over the first match of a selector, or clear it.
2197
+ * Uses CDP Overlay, so the page DOM is never touched.
2198
+ */
2199
+ async highlight(
2200
+ session: BrowserSessionId,
2201
+ request: BrowserHighlightRequest,
2202
+ signal?: AbortSignal,
2203
+ ): Promise<BrowserHighlightResult> {
2204
+ const s = this.session(session)
2205
+ const { handle } = this.activeTab(s)
2206
+ signal?.throwIfAborted()
2207
+ if (request.clear === true) {
2208
+ this.ignoreHostFailure(handle.sendCommand(CDP_OVERLAY_HIDE_HIGHLIGHT, {}))
2209
+ return { matched: false, cleared: true }
2210
+ }
2211
+ const selector = request.selector ?? ''
2212
+ if (selector === '') {
2213
+ throw new BrowserError('browser: highlight needs a selector, or clear: true', 'BROWSER_HIGHLIGHT_INVALID')
2214
+ }
2215
+ const doc = await handle.sendCommand('DOM.getDocument', {})
2216
+ const rootId = (doc as { root?: { nodeId?: number } }).root?.nodeId
2217
+ if (typeof rootId !== 'number') {
2218
+ throw new BrowserError('browser: could not read the document root', 'BROWSER_HIGHLIGHT_FAILED')
2219
+ }
2220
+ const found = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector })
2221
+ const nodeId = (found as { nodeId?: number }).nodeId ?? 0
2222
+ if (nodeId === 0) return { matched: false, cleared: false }
2223
+ let box: { x: number; y: number; width: number; height: number } | undefined
2224
+ try {
2225
+ const model = await handle.sendCommand('DOM.getBoxModel', { nodeId })
2226
+ const quad = (model as { model?: { content?: number[] } }).model?.content
2227
+ if (Array.isArray(quad) && quad.length >= 8) {
2228
+ const xs = [quad[0], quad[2], quad[4], quad[6]]
2229
+ const ys = [quad[1], quad[3], quad[5], quad[7]]
2230
+ const x = Math.min(...xs)
2231
+ const y = Math.min(...ys)
2232
+ box = { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }
2233
+ }
2234
+ } catch { /* a zero-size element has no box model; the box is still highlighted */ }
2235
+ await handle.sendCommand(CDP_OVERLAY_ENABLE, {})
2236
+ await handle.sendCommand(CDP_OVERLAY_HIGHLIGHT_NODE, {
2237
+ nodeId,
2238
+ highlightConfig: {
2239
+ showInfo: true,
2240
+ contentColor: { r: 77, g: 107, b: 254, a: 0.28 },
2241
+ borderColor: { r: 77, g: 107, b: 254, a: 0.9 },
2242
+ },
2243
+ })
2244
+ this.record(s, 'highlight', { selector }, true, { result: `node ${nodeId}` })
2245
+ return { matched: true, cleared: false, nodeId, ...box !== undefined ? { box } : {} }
2246
+ }
2247
+
2248
+
2249
+ /** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
2250
+ async pdf(
2251
+ session: BrowserSessionId,
2252
+ request: BrowserPdfRequest,
2253
+ signal?: AbortSignal,
2254
+ ): Promise<BrowserPdfResult> {
2255
+ const s = this.session(session)
2256
+ const { handle } = this.activeTab(s)
2257
+ signal?.throwIfAborted()
2258
+ // Resolve first: a bad path must fail before we spend time printing.
2259
+ const target = resolveWritePath(request.savePath, this.writeRoots)
2260
+ // Electron's debugger does not implement CDP `Page.printToPDF`, so a host
2261
+ // that can print natively does it and hands back base64. Paper size arrives
2262
+ // in inches; Electron wants microns.
2263
+ const timeoutMs = 60_000
2264
+ const printOnce = async (): Promise<string> => {
2265
+ if (handle.printToPdf !== undefined) {
2266
+ const options: Record<string, unknown> = {
2267
+ // Backgrounds off by default would print dark pages as white paper.
2268
+ printBackground: request.printBackground !== false,
2269
+ landscape: request.landscape === true,
2270
+ }
2271
+ if (request.paperWidth !== undefined || request.paperHeight !== undefined) {
2272
+ options.pageSize = {
2273
+ width: Math.round((request.paperWidth ?? 8.5) * 25_400),
2274
+ height: Math.round((request.paperHeight ?? 11) * 25_400),
2275
+ }
2276
+ }
2277
+ const printed = await handle.printToPdf(options)
2278
+ return printed.base64
2279
+ }
2280
+ const params: Record<string, unknown> = {
2281
+ printBackground: request.printBackground !== false,
2282
+ landscape: request.landscape === true,
2283
+ }
2284
+ if (request.paperWidth !== undefined) params.paperWidth = request.paperWidth
2285
+ if (request.paperHeight !== undefined) params.paperHeight = request.paperHeight
2286
+ const result = await handle.sendCommand(CDP_PAGE_PRINT_TO_PDF, params)
2287
+ return typeof result.data === 'string' ? result.data : ''
2288
+ }
2289
+ const data = await withTimeout(printOnce(), timeoutMs, signal, `browser: pdf timed out after ${timeoutMs}ms`)
2290
+ if (data === '') {
2291
+ throw new BrowserError('browser: printToPDF returned no data', 'BROWSER_PDF_FAILED')
2292
+ }
2293
+ const bytes = Buffer.from(data, 'base64')
2294
+ writeFileSync(target, bytes)
2295
+ this.record(s, 'pdf', { savePath: request.savePath, landscape: request.landscape === true }, true, { result: `${bytes.length} bytes` })
2296
+ return { path: request.savePath, bytes: bytes.length }
2297
+ }
2298
+
2299
+
2088
2300
  async screenshot(
2089
2301
  session: BrowserSessionId,
2090
2302
  request?: { readonly fullPage?: boolean; readonly savePath?: string },
@@ -2372,6 +2584,25 @@ export class ElectronBrowserProvider implements BrowserProvider {
2372
2584
  return this.localTaskInfo(s)
2373
2585
  }
2374
2586
 
2587
+ /**
2588
+ * Mirror one task's Agent todo list into the host so the floating orb can
2589
+ * render it. The list is the DSH session projection for that task's session;
2590
+ * only this side can read it, and only the host can reach the chrome.
2591
+ *
2592
+ * Unknown task keys are still stored: an Agent usually writes its plan before
2593
+ * it opens a page, and the orb should show that plan the moment the task has a
2594
+ * view rather than wait for the next write.
2595
+ */
2596
+ async pushTaskTodos(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
2597
+ this.taskTodos.set(taskKey, todos)
2598
+ if (typeof this.host.setTaskTodos !== 'function') return
2599
+ // 只在「这个任务真的开过浏览器」时才转发:宿主 RPC 的第一步是 ready(),
2600
+ // 而 ready() 会 spawn 一个 Electron 子进程 —— 一个和浏览器无关的 todo_write
2601
+ // 不该把几百 MB 的宿主拉起来。缓存照存,open() 会给新会话补推。
2602
+ if (!this.sessionsByTask.has(taskKey)) return
2603
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
2604
+ }
2605
+
2375
2606
  /** Hand control to the user or return it to Agent-driven actions. */
2376
2607
  async setHandoff(session: BrowserSessionId, state: BrowserHandoffState): Promise<BrowserTaskInfo> {
2377
2608
  return this.updateTask(session, state === 'waiting-user'
@@ -2474,6 +2705,35 @@ export class ElectronBrowserProvider implements BrowserProvider {
2474
2705
  }
2475
2706
  }
2476
2707
 
2708
+ /**
2709
+ * Put the shared window on screen and raise it. With no session yet this
2710
+ * opens the default one, which spawns the host and creates the first view —
2711
+ * that is what makes the window appear. With a session already open the
2712
+ * window exists, so it only has to be raised.
2713
+ */
2714
+ async ensureWindowVisible(): Promise<void> {
2715
+ // With no session yet this opens the default one. Its handle is lazy —
2716
+ // `createView` only records a deferred view — so the raise below is ALSO
2717
+ // what spawns the host and puts the window on screen. Returning right after
2718
+ // `open()` would leave the window to appear whenever some later call
2719
+ // happened to materialize the view.
2720
+ if (this.sessions.size === 0) await this.open()
2721
+ for (const s of this.sessions.values()) {
2722
+ const tab = s.tabs[s.activeIndex] ?? s.tabs[0]
2723
+ if (tab === undefined) continue
2724
+ if (tab.handle.focusWindow !== undefined) await tab.handle.focusWindow()
2725
+ break
2726
+ }
2727
+ // 「把窗口带出来」也是「有人要看了」——顺手把手里存的计划补推一遍。
2728
+ // 宿主重启会丢掉它内存里那份,而桥接只在清单**变化**时推;没有这一步,
2729
+ // 重启后球上会一直空着,直到 Agent 再写一次 todo(review 报的 M1)。
2730
+ for (const [taskKey, todos] of this.taskTodos) {
2731
+ if (!this.sessionsByTask.has(taskKey)) continue
2732
+ if (typeof this.host.setTaskTodos !== 'function') break
2733
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined)
2734
+ }
2735
+ }
2736
+
2477
2737
  /** Close the session and destroy all its views. Idempotent. */
2478
2738
  close(session: BrowserSessionId): Promise<void> {
2479
2739
  const existing = this.sessions.get(session)
@@ -2487,6 +2747,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
2487
2747
  }
2488
2748
  if (replacement === undefined) {
2489
2749
  this.taskStates.delete(existing.taskKey)
2750
+ // 这张表和 taskStates 一样是「每个任务一条」,关掉最后一个会话就该回收。
2751
+ this.taskTodos.delete(existing.taskKey)
2490
2752
  }
2491
2753
  }
2492
2754
  return Promise.resolve()
@@ -2588,8 +2850,24 @@ export class ElectronBrowserProvider implements BrowserProvider {
2588
2850
  }
2589
2851
  const script = `(() => {
2590
2852
  if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
2853
+ // 框架里的元素:先进入对应的同源 iframe,坐标再加上框架自己的矩形。
2854
+ const frameIndex = ${JSON.stringify(target.frame ?? null)}
2855
+ let scope = document
2856
+ let dx = 0
2857
+ let dy = 0
2858
+ if (frameIndex !== null) {
2859
+ const frame = document.querySelectorAll('iframe')[frameIndex]
2860
+ if (!frame) return { stale: 'frame missing' }
2861
+ let inner = null
2862
+ try { inner = frame.contentDocument } catch { inner = null }
2863
+ if (inner === null) return { stale: 'frame is cross-origin' }
2864
+ scope = inner
2865
+ const fr = frame.getBoundingClientRect()
2866
+ dx = fr.left
2867
+ dy = fr.top
2868
+ }
2591
2869
  let el
2592
- try { el = document.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2870
+ try { el = scope.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2593
2871
  if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
2594
2872
  const fingerprint = [
2595
2873
  el.tagName,
@@ -2606,8 +2884,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
2606
2884
  if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
2607
2885
  const root = document.documentElement
2608
2886
  return {
2609
- x: Math.round(rect.x + rect.width / 2),
2610
- y: Math.round(rect.y + rect.height / 2),
2887
+ x: Math.round(rect.x + rect.width / 2 + dx),
2888
+ y: Math.round(rect.y + rect.height / 2 + dy),
2611
2889
  scrollX: window.scrollX,
2612
2890
  scrollY: window.scrollY,
2613
2891
  maxX: Math.max(0, root.scrollWidth - window.innerWidth),
@@ -24,7 +24,7 @@ import { createServer, type Server, type Socket } from 'node:net'
24
24
  import { fileURLToPath } from 'node:url'
25
25
  import type { ChromeHostEvent, ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts'
26
26
  import { BrowserError } from '../browser/types.ts'
27
- import type { BrowserTaskInfo, BrowserTaskUpdate, ExportedCookie } from '../browser/types.ts'
27
+ import type { BrowserTaskInfo, BrowserTaskTodo, BrowserTaskUpdate, ExportedCookie } from '../browser/types.ts'
28
28
 
29
29
  /** How long to wait for the child to signal readiness before failing. */
30
30
  const READY_TIMEOUT_MS = 20_000
@@ -294,7 +294,8 @@ class ElectronChildClient {
294
294
  delete env.NODE_OPTIONS
295
295
  // The chrome's world is the child's choice, so it travels as an argument.
296
296
  const childArgs = [
297
- ...this.chromeWorld === 'isolated' ? ['--chrome-world', 'isolated'] : [],
297
+ // 缺省也按 isolated 走:主世界会把 Agent 的计划暴露给页面脚本。
298
+ ...this.chromeWorld === 'main' ? [] : ['--chrome-world', 'isolated'],
298
299
  ...this.fingerprintArgs,
299
300
  ]
300
301
  this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port), ...childArgs], {
@@ -481,6 +482,31 @@ class RemoteView implements ElectronViewHandle {
481
482
  return this.client.call<{ base64: string; width: number; height: number }>('capture', { viewId: this.id }, RPC_TRANSFER_TIMEOUT_MS)
482
483
  }
483
484
 
485
+ /**
486
+ * Print the view to a PDF in the child process. Electron's debugger has no
487
+ * `Page.printToPDF`, so this is the only working print path; the bytes cross
488
+ * the RPC socket base64-encoded.
489
+ */
490
+ printToPdf(options: Record<string, unknown>): Promise<{ base64: string }> {
491
+ return this.client.call<{ base64: string }>('printToPdf', { viewId: this.id, options }, RPC_TRANSFER_TIMEOUT_MS)
492
+ }
493
+
494
+ /**
495
+ * Run a snippet in the world the *page's* injected chrome lives in.
496
+ *
497
+ * In isolated mode (the default) the chrome's globals are not reachable from
498
+ * the page's own world, and creating a world with the same name from here
499
+ * yields a different, empty context — the host has to resolve its own.
500
+ */
501
+ chromeWorldEval(expression: string): Promise<{ result?: { value?: unknown } }> {
502
+ return this.client.call<{ result?: { value?: unknown } }>('chromeWorldEval', { viewId: this.id, expression }, RPC_QUERY_TIMEOUT_MS)
503
+ }
504
+
505
+ /** Raise the child's window (show + restore + focus). */
506
+ focusWindow(): Promise<void> {
507
+ return this.client.call<Record<string, never>>('focusWindow', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(() => undefined)
508
+ }
509
+
484
510
  /** Export the session's cookies (login state). */
485
511
  flushAuth(): Promise<ExportedCookie[]> {
486
512
  return this.client.call<{ cookies: ExportedCookie[] }>('flushAuth', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(r => r.cookies)
@@ -819,6 +845,14 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
819
845
  return result.task ?? undefined
820
846
  }
821
847
 
848
+ /** Mirror one task's Agent todo list into the self-hosted workspace. */
849
+ async setTaskTodos(key: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
850
+ await this.ready()
851
+ const client = this.client
852
+ if (client === undefined) throw new Error('browser host unavailable')
853
+ await client.call<Record<string, never>>('setTaskTodos', { key, todos }, RPC_QUERY_TIMEOUT_MS)
854
+ }
855
+
822
856
  /** Shut the child and the RPC server down. */
823
857
  dispose(): void {
824
858
  this.disposed = true
@@ -933,6 +967,19 @@ export class DeferredRemoteView implements ElectronViewHandle {
933
967
  return this.withView(view => view.sendCommand(method, params), settle, method)
934
968
  }
935
969
 
970
+ async printToPdf(options: Record<string, unknown>): Promise<{ base64: string }> {
971
+ return this.withView(view => view.printToPdf(options))
972
+ }
973
+
974
+ /** Evaluate in the injected chrome's own world (see the view-level method). */
975
+ async chromeWorldEval(expression: string): Promise<{ result?: { value?: unknown } }> {
976
+ return this.withView(view => view.chromeWorldEval(expression))
977
+ }
978
+
979
+ async focusWindow(): Promise<void> {
980
+ return this.withView(view => view.focusWindow())
981
+ }
982
+
936
983
  async download(url: string, savePath: string): Promise<void> {
937
984
  return this.withView(view => view.download(url, savePath))
938
985
  }