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
@@ -44,6 +44,12 @@ const AGENT_INPUT_SUPPRESSION_MS = 900;
44
44
  export const ELECTRON_BROWSER_PROVIDER_ID = 'electron';
45
45
  /** CDP method for a full-page screenshot capture. */
46
46
  export const CDP_PAGE_CAPTURE_SCREENSHOT = 'Page.captureScreenshot';
47
+ /** CDP method that renders the current document to PDF. */
48
+ export const CDP_PAGE_PRINT_TO_PDF = 'Page.printToPDF';
49
+ /** CDP methods that draw the DevTools highlight box without touching the DOM. */
50
+ export const CDP_OVERLAY_ENABLE = 'Overlay.enable';
51
+ export const CDP_OVERLAY_HIGHLIGHT_NODE = 'Overlay.highlightNode';
52
+ export const CDP_OVERLAY_HIDE_HIGHLIGHT = 'Overlay.hideHighlight';
47
53
  /** CDP method for runtime evaluation (the execute path). */
48
54
  export const CDP_RUNTIME_EVALUATE = 'Runtime.evaluate';
49
55
  /**
@@ -196,7 +202,6 @@ export const CDP_PAGE_NAVIGATE_TO_HISTORY_ENTRY = 'Page.navigateToHistoryEntry';
196
202
  export const CDP_PAGE_RELOAD = 'Page.reload';
197
203
  export const CDP_PAGE_STOP_LOADING = 'Page.stopLoading';
198
204
  /** Cap on content returned by a snapshot fetch to keep the wire bounded. */
199
- const SNAPSHOT_LABEL_MAX = 120;
200
205
  /** An input dispatch must not outlive this: a blocked renderer never acknowledges. */
201
206
  const INPUT_DISPATCH_TIMEOUT_MS = 15_000;
202
207
  /**
@@ -327,6 +332,14 @@ export class ElectronBrowserProvider {
327
332
  /** Stable task-key index so callers can recover a session after tool-layer state loss. */
328
333
  sessionsByTask = new Map();
329
334
  taskStates = new Map();
335
+ /**
336
+ * The Agent's todo list per task, kept here as well as in the host.
337
+ *
338
+ * The host's copy is in memory and dies with the host process, and the bridge
339
+ * only pushes on a todo change — so without this cache a host restart left the
340
+ * floating orb with no plan until the Agent happened to write its list again.
341
+ */
342
+ taskTodos = new Map();
330
343
  httpOnly;
331
344
  snapshotMaxElements;
332
345
  contentMaxChars;
@@ -449,12 +462,24 @@ export class ElectronBrowserProvider {
449
462
  if (typeof labelable?.label === 'function')
450
463
  await labelable.label(taskLabel).catch(() => undefined);
451
464
  }
465
+ // 宿主重启后它内存里那份计划是空的,而这是**已存在会话**唯一会走到的入口
466
+ // (ensureWindowVisible 也补推一次)—— 不推的话球上会一直空着。
467
+ const cached = this.taskTodos.get(taskKey);
468
+ if (cached !== undefined && typeof this.host.setTaskTodos === 'function') {
469
+ void this.host.setTaskTodos(taskKey, cached).catch(() => undefined);
470
+ }
452
471
  return existing.id;
453
472
  }
454
473
  const handle = this.host.createView(taskKey, taskLabel === '' ? undefined : taskLabel);
455
474
  const id = `browser:${randomUUID()}`;
456
475
  this.sessions.set(id, { id, taskKey, taskLabel, tabs: [this.createTab(handle)], activeIndex: 0, history: [], nextSeq: 1 });
457
476
  this.sessionsByTask.set(taskKey, id);
477
+ // A fresh view means a host that may never have seen this task's plan (a host
478
+ // restart drops the host's copy), so replay what the Agent last wrote.
479
+ const cachedTodos = this.taskTodos.get(taskKey);
480
+ if (cachedTodos !== undefined && typeof this.host.setTaskTodos === 'function') {
481
+ void this.host.setTaskTodos(taskKey, cachedTodos).catch(() => undefined);
482
+ }
458
483
  if (!this.taskStates.has(taskKey)) {
459
484
  this.taskStates.set(taskKey, { status: 'idle', control: 'agent', updatedAt: Date.now() });
460
485
  }
@@ -756,7 +781,7 @@ export class ElectronBrowserProvider {
756
781
  if (text) return tag + ':has-text("' + text.replace(/"/g, '\\\\"') + '")'
757
782
  return tag
758
783
  }
759
- const pathOf = (el) => {
784
+ const pathOf = (el, doc) => {
760
785
  if (el.id) return '#' + CSS.escape(el.id)
761
786
  const parts = []
762
787
  let node = el
@@ -768,7 +793,7 @@ export class ElectronBrowserProvider {
768
793
  if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')'
769
794
  }
770
795
  parts.unshift(part)
771
- if (node === document.body) break
796
+ if (node === doc.body) break
772
797
  node = parent
773
798
  }
774
799
  return parts.join(' > ')
@@ -783,42 +808,65 @@ export class ElectronBrowserProvider {
783
808
  ].join('\u001f')
784
809
  const url = location.href
785
810
  const title = document.title || undefined
786
- const els = [...document.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [contenteditable="true"]')]
787
811
  const out = []
788
812
  let capped = false
789
- for (const el of els) {
790
- if (el.closest('[data-dsh-browser-chrome]')) continue
791
- const r = el.getBoundingClientRect()
792
- const cs = getComputedStyle(el)
793
- if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
794
- const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'input'))
795
- : el.tagName === 'TEXTAREA' ? 'textarea'
796
- : el.tagName === 'SELECT' ? 'select'
797
- : el.tagName === 'BUTTON' ? 'button'
798
- : el.tagName === 'A' ? 'link' : 'other'
799
- 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)})
800
- if (!label && kind !== 'link') continue
801
- // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
802
- if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
803
- if (out.length >= cap) { capped = true; break }
804
- out.push({
805
- ref: out.length + 1,
806
- kind,
807
- label,
808
- selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
809
- loc: locatorOf(el),
810
- path: pathOf(el),
811
- fingerprint: fingerprintOf(el),
812
- x: Math.round(r.x + r.width / 2),
813
- y: Math.round(r.y + r.height / 2),
814
- })
813
+ // 同一套判定,用在顶层文档和每个同源子框架上;dx/dy 把框架内的坐标换算成页面坐标。
814
+ const collect = (doc, frameIndex, dx, dy) => {
815
+ const els = [...doc.querySelectorAll('input, textarea, select, button, a[href], [role="button"], [role="searchbox"], [role="link"], [contenteditable="true"]')]
816
+ for (const el of els) {
817
+ if (el.closest('[data-dsh-browser-chrome]')) continue
818
+ const r = el.getBoundingClientRect()
819
+ const cs = getComputedStyle(el)
820
+ if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') continue
821
+ const kind = el.tagName === 'INPUT' ? (el.type === 'checkbox' ? 'checkbox' : (el.type === 'submit' || el.type === 'button' ? 'button' : 'text'))
822
+ : el.tagName === 'TEXTAREA' ? 'textarea'
823
+ : el.tagName === 'SELECT' ? 'select'
824
+ : el.tagName === 'BUTTON' ? 'button'
825
+ : el.tagName === 'A' ? 'link' : 'other'
826
+ const label = (el.getAttribute('aria-label') || el.placeholder || el.textContent || el.value || el.name || el.id || '').replace(/\s+/g, ' ').trim().slice(0, 120)
827
+ if (!label && kind !== 'link') continue
828
+ // 过滤放在计上限**之前**:默认上限是 60,先截断就永远搜不到后面的元素。
829
+ if (query !== '' && (kind + ' ' + label).toLowerCase().indexOf(query) === -1) continue
830
+ if (out.length >= cap) { capped = true; return }
831
+ out.push({
832
+ ref: out.length + 1,
833
+ kind,
834
+ label,
835
+ ...(frameIndex === undefined ? {} : { frame: frameIndex }),
836
+ selector: el.id ? '#' + CSS.escape(el.id) : (el.name ? '[name=' + JSON.stringify(el.name) + ']' : ''),
837
+ loc: locatorOf(el),
838
+ path: pathOf(el, doc),
839
+ fingerprint: fingerprintOf(el),
840
+ x: Math.round(r.x + r.width / 2 + dx),
841
+ y: Math.round(r.y + r.height / 2 + dy),
842
+ })
843
+ }
844
+ }
845
+ collect(document, undefined, 0, 0)
846
+ // 子框架:同源的走进去(元素带 frame 序号、坐标加上框架自己的矩形);
847
+ // 跨源的 contentDocument 读不到,就只记一条 readable:false —— 不假装能读。
848
+ const frames = []
849
+ const frameEls = [...document.querySelectorAll('iframe')]
850
+ for (let i = 0; i < frameEls.length; i++) {
851
+ const frame = frameEls[i]
852
+ const src = frame.getAttribute('src') || ''
853
+ let inner = null
854
+ try { inner = frame.contentDocument } catch (error) { inner = null }
855
+ if (inner === null) {
856
+ frames.push({ index: i, url: src, readable: false })
857
+ continue
858
+ }
859
+ const fr = frame.getBoundingClientRect()
860
+ frames.push({ index: i, url: inner.location.href || src, readable: true })
861
+ if (out.length >= cap) { capped = true; continue }
862
+ collect(inner, i, fr.left, fr.top)
815
863
  }
816
864
  const challenge = ${CHALLENGE_DETECT_EXPRESSION}
817
865
  const chromeHost = document.getElementById('__dsh_browser_chrome_host__')
818
866
  const userControlling = chromeHost?.getAttribute('data-dsh-user-active') === '1'
819
867
  // Only an early break means truncation: exactly cap candidates is a
820
868
  // complete inventory, not a truncated one.
821
- return { url, title, elements: out, truncated: capped, challenge, userControlling }
869
+ return { url, title, elements: out, truncated: capped, frames, challenge, userControlling }
822
870
  })()`;
823
871
  // Same hang guard as execute: a renderer that has not committed after
824
872
  // navigate would otherwise block snapshot forever.
@@ -851,7 +899,11 @@ export class ElectronBrowserProvider {
851
899
  const snapshotId = `snapshot:${randomUUID()}`;
852
900
  const targets = new Map();
853
901
  for (const element of value.elements) {
854
- targets.set(element.ref, { path: element.path, fingerprint: element.fingerprint });
902
+ targets.set(element.ref, {
903
+ path: element.path,
904
+ fingerprint: element.fingerprint,
905
+ ...element.frame !== undefined ? { frame: element.frame } : {},
906
+ });
855
907
  }
856
908
  tab.snapshots.set(snapshotId, { tabId: tab.id, url: value.url, epoch: tab.navigationEpoch, targets });
857
909
  while (tab.snapshots.size > 10) {
@@ -865,6 +917,7 @@ export class ElectronBrowserProvider {
865
917
  url: value.url,
866
918
  ...value.title !== undefined ? { title: value.title } : {},
867
919
  elements: value.elements.map(({ path: _path, fingerprint: _fingerprint, ...element }) => element),
920
+ ...value.frames !== undefined ? { frames: value.frames } : {},
868
921
  truncated: value.truncated,
869
922
  ...value.challenge !== undefined ? { challenge: value.challenge } : {},
870
923
  ...value.userControlling !== undefined ? { userControlling: value.userControlling } : {},
@@ -1126,27 +1179,55 @@ export class ElectronBrowserProvider {
1126
1179
  const s = this.session(session);
1127
1180
  return this.waitForElementTab(s, this.activeTab(s), request, signal);
1128
1181
  }
1129
- /** Poll one tab until the selector matches. */
1182
+ /** Poll one tab until the selector and/or text reaches the requested state. */
1130
1183
  async waitForElementTab(s, tab, request, signal) {
1131
1184
  const { handle } = tab;
1132
1185
  signal?.throwIfAborted();
1133
1186
  const timeoutMs = request.timeoutMs ?? 15_000;
1134
- const visible = request.visible !== false;
1135
- const selector = request.selector;
1187
+ const selector = request.selector ?? '';
1188
+ const wanted = request.text;
1189
+ const state = request.state ?? (request.visible === false ? 'attached' : 'visible');
1190
+ if (selector === '' && wanted === undefined) {
1191
+ throw new BrowserError('browser: waitForElement needs a selector, some text, or both', 'BROWSER_WAIT_INVALID');
1192
+ }
1193
+ if (selector === '' && (state === 'hidden' || state === 'detached')) {
1194
+ throw new BrowserError(`browser: state "${state}" needs a selector to watch disappear`, 'BROWSER_WAIT_INVALID');
1195
+ }
1136
1196
  const script = `(() => {
1197
+ const selector = ${JSON.stringify(selector)}
1198
+ const wanted = ${JSON.stringify(wanted ?? null)}
1199
+ const state = ${JSON.stringify(state)}
1137
1200
  let el = null
1138
- try { el = document.querySelector(${JSON.stringify(selector)}) } catch (e) { return { error: String(e) } }
1139
- if (!el) return null
1140
- if (${visible}) {
1141
- const r = el.getBoundingClientRect()
1142
- const cs = getComputedStyle(el)
1143
- if (r.width < 4 || r.height < 4 || cs.visibility === 'hidden' || cs.display === 'none') return null
1201
+ if (selector !== '') {
1202
+ try { el = document.querySelector(selector) } catch (e) { return { error: String(e) } }
1203
+ }
1204
+ const visibleNow = node => {
1205
+ if (node === null) return false
1206
+ const r = node.getBoundingClientRect()
1207
+ if (r.width < 4 || r.height < 4) return false
1208
+ const cs = getComputedStyle(node)
1209
+ return cs.visibility !== 'hidden' && cs.display !== 'none'
1144
1210
  }
1211
+ const textOf = node => (node === null ? '' : (node.textContent || '')).replace(/\\s+/g, ' ').trim()
1212
+ const attached = el !== null
1213
+ const visible = visibleNow(el)
1214
+ let met
1215
+ if (selector === '') {
1216
+ // 只等文字时没有元素可量:文字在不在,本身就是那个状态。
1217
+ // (以前这里仍按 visibleNow(null) === false 判定,于是默认的 visible 永远不成立。)
1218
+ const present = textOf(document.body).includes(wanted)
1219
+ met = state === 'hidden' || state === 'detached' ? !present : present
1220
+ } else {
1221
+ met = state === 'attached' ? attached : state === 'visible' ? visible : state === 'hidden' ? !visible : state === 'detached' ? !attached : false
1222
+ if (met && wanted !== null) met = textOf(el).includes(wanted)
1223
+ }
1224
+ if (!met) return null
1145
1225
  return {
1146
1226
  found: true,
1147
- selector: ${JSON.stringify(selector)},
1148
- tag: el.tagName.toLowerCase(),
1149
- text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200),
1227
+ state,
1228
+ selector,
1229
+ tag: attached ? el.tagName.toLowerCase() : '',
1230
+ text: (wanted !== null && !attached ? wanted : textOf(selector === '' ? document.body : el)).slice(0, 200),
1150
1231
  }
1151
1232
  })()`;
1152
1233
  const deadline = Date.now() + timeoutMs;
@@ -1160,8 +1241,8 @@ export class ElectronBrowserProvider {
1160
1241
  else {
1161
1242
  const value = result.value;
1162
1243
  if (value?.found === true && typeof value.tag === 'string') {
1163
- this.record(s, 'waitForElement', { selector, timeoutMs, visible }, true, { result: value.tag });
1164
- return { found: true, selector, tag: value.tag, text: value.text ?? '' };
1244
+ this.record(s, 'waitForElement', { selector, text: wanted, state, timeoutMs }, true, { result: value.tag ?? '' });
1245
+ return { found: true, state, selector, tag: value.tag, text: value.text ?? '' };
1165
1246
  }
1166
1247
  if (value?.error !== undefined) {
1167
1248
  // A malformed selector can never start matching, so fail now instead of
@@ -1171,7 +1252,8 @@ export class ElectronBrowserProvider {
1171
1252
  }
1172
1253
  await new Promise(resolve => setTimeout(resolve, Math.min(250, Math.max(deadline - Date.now(), 1))));
1173
1254
  }
1174
- throw new BrowserError(`browser: element "${selector}" did not appear within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT');
1255
+ const what = selector === '' ? `text ${JSON.stringify(wanted)}` : `"${selector}"`;
1256
+ throw new BrowserError(`browser: ${what} did not reach state "${state}" within ${timeoutMs}ms${lastError !== undefined ? ` (${lastError})` : ''}`, 'BROWSER_WAIT_TIMEOUT');
1175
1257
  }
1176
1258
  /** Type into the focused element. */
1177
1259
  async type(session, request, signal) {
@@ -1664,6 +1746,103 @@ export class ElectronBrowserProvider {
1664
1746
  this.showActive(s);
1665
1747
  }
1666
1748
  /** Capture the current page, optionally full-page. PNG only (CDP JPEG hangs on Electron 43). */
1749
+ /**
1750
+ * Draw the DevTools highlight box over the first match of a selector, or clear it.
1751
+ * Uses CDP Overlay, so the page DOM is never touched.
1752
+ */
1753
+ async highlight(session, request, signal) {
1754
+ const s = this.session(session);
1755
+ const { handle } = this.activeTab(s);
1756
+ signal?.throwIfAborted();
1757
+ if (request.clear === true) {
1758
+ this.ignoreHostFailure(handle.sendCommand(CDP_OVERLAY_HIDE_HIGHLIGHT, {}));
1759
+ return { matched: false, cleared: true };
1760
+ }
1761
+ const selector = request.selector ?? '';
1762
+ if (selector === '') {
1763
+ throw new BrowserError('browser: highlight needs a selector, or clear: true', 'BROWSER_HIGHLIGHT_INVALID');
1764
+ }
1765
+ const doc = await handle.sendCommand('DOM.getDocument', {});
1766
+ const rootId = doc.root?.nodeId;
1767
+ if (typeof rootId !== 'number') {
1768
+ throw new BrowserError('browser: could not read the document root', 'BROWSER_HIGHLIGHT_FAILED');
1769
+ }
1770
+ const found = await handle.sendCommand('DOM.querySelector', { nodeId: rootId, selector });
1771
+ const nodeId = found.nodeId ?? 0;
1772
+ if (nodeId === 0)
1773
+ return { matched: false, cleared: false };
1774
+ let box;
1775
+ try {
1776
+ const model = await handle.sendCommand('DOM.getBoxModel', { nodeId });
1777
+ const quad = model.model?.content;
1778
+ if (Array.isArray(quad) && quad.length >= 8) {
1779
+ const xs = [quad[0], quad[2], quad[4], quad[6]];
1780
+ const ys = [quad[1], quad[3], quad[5], quad[7]];
1781
+ const x = Math.min(...xs);
1782
+ const y = Math.min(...ys);
1783
+ box = { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
1784
+ }
1785
+ }
1786
+ catch { /* a zero-size element has no box model; the box is still highlighted */ }
1787
+ await handle.sendCommand(CDP_OVERLAY_ENABLE, {});
1788
+ await handle.sendCommand(CDP_OVERLAY_HIGHLIGHT_NODE, {
1789
+ nodeId,
1790
+ highlightConfig: {
1791
+ showInfo: true,
1792
+ contentColor: { r: 77, g: 107, b: 254, a: 0.28 },
1793
+ borderColor: { r: 77, g: 107, b: 254, a: 0.9 },
1794
+ },
1795
+ });
1796
+ this.record(s, 'highlight', { selector }, true, { result: `node ${nodeId}` });
1797
+ return { matched: true, cleared: false, nodeId, ...box !== undefined ? { box } : {} };
1798
+ }
1799
+ /** Print the active tab to a PDF file (Chrome's "Save as PDF"). */
1800
+ async pdf(session, request, signal) {
1801
+ const s = this.session(session);
1802
+ const { handle } = this.activeTab(s);
1803
+ signal?.throwIfAborted();
1804
+ // Resolve first: a bad path must fail before we spend time printing.
1805
+ const target = resolveWritePath(request.savePath, this.writeRoots);
1806
+ // Electron's debugger does not implement CDP `Page.printToPDF`, so a host
1807
+ // that can print natively does it and hands back base64. Paper size arrives
1808
+ // in inches; Electron wants microns.
1809
+ const timeoutMs = 60_000;
1810
+ const printOnce = async () => {
1811
+ if (handle.printToPdf !== undefined) {
1812
+ const options = {
1813
+ // Backgrounds off by default would print dark pages as white paper.
1814
+ printBackground: request.printBackground !== false,
1815
+ landscape: request.landscape === true,
1816
+ };
1817
+ if (request.paperWidth !== undefined || request.paperHeight !== undefined) {
1818
+ options.pageSize = {
1819
+ width: Math.round((request.paperWidth ?? 8.5) * 25_400),
1820
+ height: Math.round((request.paperHeight ?? 11) * 25_400),
1821
+ };
1822
+ }
1823
+ const printed = await handle.printToPdf(options);
1824
+ return printed.base64;
1825
+ }
1826
+ const params = {
1827
+ printBackground: request.printBackground !== false,
1828
+ landscape: request.landscape === true,
1829
+ };
1830
+ if (request.paperWidth !== undefined)
1831
+ params.paperWidth = request.paperWidth;
1832
+ if (request.paperHeight !== undefined)
1833
+ params.paperHeight = request.paperHeight;
1834
+ const result = await handle.sendCommand(CDP_PAGE_PRINT_TO_PDF, params);
1835
+ return typeof result.data === 'string' ? result.data : '';
1836
+ };
1837
+ const data = await withTimeout(printOnce(), timeoutMs, signal, `browser: pdf timed out after ${timeoutMs}ms`);
1838
+ if (data === '') {
1839
+ throw new BrowserError('browser: printToPDF returned no data', 'BROWSER_PDF_FAILED');
1840
+ }
1841
+ const bytes = Buffer.from(data, 'base64');
1842
+ writeFileSync(target, bytes);
1843
+ this.record(s, 'pdf', { savePath: request.savePath, landscape: request.landscape === true }, true, { result: `${bytes.length} bytes` });
1844
+ return { path: request.savePath, bytes: bytes.length };
1845
+ }
1667
1846
  async screenshot(session, request, signal) {
1668
1847
  const s = this.session(session);
1669
1848
  const { handle } = this.activeTab(s);
@@ -1932,6 +2111,26 @@ export class ElectronBrowserProvider {
1932
2111
  }
1933
2112
  return this.localTaskInfo(s);
1934
2113
  }
2114
+ /**
2115
+ * Mirror one task's Agent todo list into the host so the floating orb can
2116
+ * render it. The list is the DSH session projection for that task's session;
2117
+ * only this side can read it, and only the host can reach the chrome.
2118
+ *
2119
+ * Unknown task keys are still stored: an Agent usually writes its plan before
2120
+ * it opens a page, and the orb should show that plan the moment the task has a
2121
+ * view rather than wait for the next write.
2122
+ */
2123
+ async pushTaskTodos(taskKey, todos) {
2124
+ this.taskTodos.set(taskKey, todos);
2125
+ if (typeof this.host.setTaskTodos !== 'function')
2126
+ return;
2127
+ // 只在「这个任务真的开过浏览器」时才转发:宿主 RPC 的第一步是 ready(),
2128
+ // 而 ready() 会 spawn 一个 Electron 子进程 —— 一个和浏览器无关的 todo_write
2129
+ // 不该把几百 MB 的宿主拉起来。缓存照存,open() 会给新会话补推。
2130
+ if (!this.sessionsByTask.has(taskKey))
2131
+ return;
2132
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined);
2133
+ }
1935
2134
  /** Hand control to the user or return it to Agent-driven actions. */
1936
2135
  async setHandoff(session, state) {
1937
2136
  return this.updateTask(session, state === 'waiting-user'
@@ -2030,6 +2229,39 @@ export class ElectronBrowserProvider {
2030
2229
  throw new BrowserError(`browser: history seq ${seq} action "${entry.action}" is not replayable`, 'BROWSER_HISTORY_NOT_REPLAYABLE');
2031
2230
  }
2032
2231
  }
2232
+ /**
2233
+ * Put the shared window on screen and raise it. With no session yet this
2234
+ * opens the default one, which spawns the host and creates the first view —
2235
+ * that is what makes the window appear. With a session already open the
2236
+ * window exists, so it only has to be raised.
2237
+ */
2238
+ async ensureWindowVisible() {
2239
+ // With no session yet this opens the default one. Its handle is lazy —
2240
+ // `createView` only records a deferred view — so the raise below is ALSO
2241
+ // what spawns the host and puts the window on screen. Returning right after
2242
+ // `open()` would leave the window to appear whenever some later call
2243
+ // happened to materialize the view.
2244
+ if (this.sessions.size === 0)
2245
+ await this.open();
2246
+ for (const s of this.sessions.values()) {
2247
+ const tab = s.tabs[s.activeIndex] ?? s.tabs[0];
2248
+ if (tab === undefined)
2249
+ continue;
2250
+ if (tab.handle.focusWindow !== undefined)
2251
+ await tab.handle.focusWindow();
2252
+ break;
2253
+ }
2254
+ // 「把窗口带出来」也是「有人要看了」——顺手把手里存的计划补推一遍。
2255
+ // 宿主重启会丢掉它内存里那份,而桥接只在清单**变化**时推;没有这一步,
2256
+ // 重启后球上会一直空着,直到 Agent 再写一次 todo(review 报的 M1)。
2257
+ for (const [taskKey, todos] of this.taskTodos) {
2258
+ if (!this.sessionsByTask.has(taskKey))
2259
+ continue;
2260
+ if (typeof this.host.setTaskTodos !== 'function')
2261
+ break;
2262
+ await this.host.setTaskTodos(taskKey, todos).catch(() => undefined);
2263
+ }
2264
+ }
2033
2265
  /** Close the session and destroy all its views. Idempotent. */
2034
2266
  close(session) {
2035
2267
  const existing = this.sessions.get(session);
@@ -2046,6 +2278,8 @@ export class ElectronBrowserProvider {
2046
2278
  }
2047
2279
  if (replacement === undefined) {
2048
2280
  this.taskStates.delete(existing.taskKey);
2281
+ // 这张表和 taskStates 一样是「每个任务一条」,关掉最后一个会话就该回收。
2282
+ this.taskTodos.delete(existing.taskKey);
2049
2283
  }
2050
2284
  }
2051
2285
  return Promise.resolve();
@@ -2123,8 +2357,24 @@ export class ElectronBrowserProvider {
2123
2357
  }
2124
2358
  const script = `(() => {
2125
2359
  if (location.href !== ${JSON.stringify(record.url)}) return { stale: 'url changed' }
2360
+ // 框架里的元素:先进入对应的同源 iframe,坐标再加上框架自己的矩形。
2361
+ const frameIndex = ${JSON.stringify(target.frame ?? null)}
2362
+ let scope = document
2363
+ let dx = 0
2364
+ let dy = 0
2365
+ if (frameIndex !== null) {
2366
+ const frame = document.querySelectorAll('iframe')[frameIndex]
2367
+ if (!frame) return { stale: 'frame missing' }
2368
+ let inner = null
2369
+ try { inner = frame.contentDocument } catch { inner = null }
2370
+ if (inner === null) return { stale: 'frame is cross-origin' }
2371
+ scope = inner
2372
+ const fr = frame.getBoundingClientRect()
2373
+ dx = fr.left
2374
+ dy = fr.top
2375
+ }
2126
2376
  let el
2127
- try { el = document.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2377
+ try { el = scope.querySelector(${JSON.stringify(target.path)}) } catch { return { stale: 'selector invalid' } }
2128
2378
  if (!el || el.closest('[data-dsh-browser-chrome]')) return { stale: 'element missing' }
2129
2379
  const fingerprint = [
2130
2380
  el.tagName,
@@ -2141,8 +2391,8 @@ export class ElectronBrowserProvider {
2141
2391
  if (rect.width < 4 || rect.height < 4 || style.visibility === 'hidden' || style.display === 'none') return { stale: 'element hidden' }
2142
2392
  const root = document.documentElement
2143
2393
  return {
2144
- x: Math.round(rect.x + rect.width / 2),
2145
- y: Math.round(rect.y + rect.height / 2),
2394
+ x: Math.round(rect.x + rect.width / 2 + dx),
2395
+ y: Math.round(rect.y + rect.height / 2 + dy),
2146
2396
  scrollX: window.scrollX,
2147
2397
  scrollY: window.scrollY,
2148
2398
  maxX: Math.max(0, root.scrollWidth - window.innerWidth),
@@ -16,7 +16,7 @@
16
16
  * @module dsh-browser-plus/browser-electron/remote-host
17
17
  */
18
18
  import type { ChromeHostEvent, ElectronBrowserViewHost, ElectronViewHandle } from './provider.ts';
19
- import type { BrowserTaskInfo, BrowserTaskUpdate } from '../browser/types.ts';
19
+ import type { BrowserTaskInfo, BrowserTaskTodo, BrowserTaskUpdate } from '../browser/types.ts';
20
20
  /**
21
21
  * Whether a usable Electron binary can be located right now. Cheap and local:
22
22
  * it only probes package metadata and the filesystem (no spawn, no network).
@@ -136,6 +136,8 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
136
136
  getTask(key: string): Promise<BrowserTaskInfo | undefined>;
137
137
  /** Update one task summary in the self-hosted visible workspace. */
138
138
  updateTask(key: string, task: BrowserTaskUpdate): Promise<BrowserTaskInfo | undefined>;
139
+ /** Mirror one task's Agent todo list into the self-hosted workspace. */
140
+ setTaskTodos(key: string, todos: readonly BrowserTaskTodo[]): Promise<void>;
139
141
  /** Shut the child and the RPC server down. */
140
142
  dispose(): void;
141
143
  }
@@ -274,7 +274,8 @@ class ElectronChildClient {
274
274
  delete env.NODE_OPTIONS;
275
275
  // The chrome's world is the child's choice, so it travels as an argument.
276
276
  const childArgs = [
277
- ...this.chromeWorld === 'isolated' ? ['--chrome-world', 'isolated'] : [],
277
+ // 缺省也按 isolated 走:主世界会把 Agent 的计划暴露给页面脚本。
278
+ ...this.chromeWorld === 'main' ? [] : ['--chrome-world', 'isolated'],
278
279
  ...this.fingerprintArgs,
279
280
  ];
280
281
  this.child = spawn(electron, [hostMainPath, '--rpc-port', String(port), ...childArgs], {
@@ -475,6 +476,28 @@ class RemoteView {
475
476
  capture() {
476
477
  return this.client.call('capture', { viewId: this.id }, RPC_TRANSFER_TIMEOUT_MS);
477
478
  }
479
+ /**
480
+ * Print the view to a PDF in the child process. Electron's debugger has no
481
+ * `Page.printToPDF`, so this is the only working print path; the bytes cross
482
+ * the RPC socket base64-encoded.
483
+ */
484
+ printToPdf(options) {
485
+ return this.client.call('printToPdf', { viewId: this.id, options }, RPC_TRANSFER_TIMEOUT_MS);
486
+ }
487
+ /**
488
+ * Run a snippet in the world the *page's* injected chrome lives in.
489
+ *
490
+ * In isolated mode (the default) the chrome's globals are not reachable from
491
+ * the page's own world, and creating a world with the same name from here
492
+ * yields a different, empty context — the host has to resolve its own.
493
+ */
494
+ chromeWorldEval(expression) {
495
+ return this.client.call('chromeWorldEval', { viewId: this.id, expression }, RPC_QUERY_TIMEOUT_MS);
496
+ }
497
+ /** Raise the child's window (show + restore + focus). */
498
+ focusWindow() {
499
+ return this.client.call('focusWindow', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(() => undefined);
500
+ }
478
501
  /** Export the session's cookies (login state). */
479
502
  flushAuth() {
480
503
  return this.client.call('flushAuth', { viewId: this.id }, RPC_COMMAND_TIMEOUT_MS).then(r => r.cookies);
@@ -793,6 +816,14 @@ export class RemoteElectronViewHost {
793
816
  const result = await client.call('updateTask', { key, task }, RPC_QUERY_TIMEOUT_MS);
794
817
  return result.task ?? undefined;
795
818
  }
819
+ /** Mirror one task's Agent todo list into the self-hosted workspace. */
820
+ async setTaskTodos(key, todos) {
821
+ await this.ready();
822
+ const client = this.client;
823
+ if (client === undefined)
824
+ throw new Error('browser host unavailable');
825
+ await client.call('setTaskTodos', { key, todos }, RPC_QUERY_TIMEOUT_MS);
826
+ }
796
827
  /** Shut the child and the RPC server down. */
797
828
  dispose() {
798
829
  this.disposed = true;
@@ -894,6 +925,16 @@ export class DeferredRemoteView {
894
925
  await settle?.();
895
926
  return this.withView(view => view.sendCommand(method, params), settle, method);
896
927
  }
928
+ async printToPdf(options) {
929
+ return this.withView(view => view.printToPdf(options));
930
+ }
931
+ /** Evaluate in the injected chrome's own world (see the view-level method). */
932
+ async chromeWorldEval(expression) {
933
+ return this.withView(view => view.chromeWorldEval(expression));
934
+ }
935
+ async focusWindow() {
936
+ return this.withView(view => view.focusWindow());
937
+ }
897
938
  async download(url, savePath) {
898
939
  return this.withView(view => view.download(url, savePath));
899
940
  }