dsh-builtin-browser 0.1.23 → 0.3.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 (48) hide show
  1. package/README.en.md +91 -3
  2. package/README.md +98 -4
  3. package/client.js +334 -0
  4. package/cordis.patch.yml +24 -5
  5. package/desktop-bridge/install.mjs +98 -0
  6. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  7. package/docs/README.md +1 -1
  8. package/docs/architecture.md +1 -1
  9. package/docs/tool-reference.md +2 -1
  10. package/lib/browser/runtime.d.ts +18 -1
  11. package/lib/browser/runtime.js +19 -0
  12. package/lib/browser/types.d.ts +33 -0
  13. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  14. package/lib/browser-electron/bridge-connection.js +133 -0
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  16. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  17. package/lib/browser-electron/entry.js +195 -11
  18. package/lib/browser-electron/history-store.d.ts +75 -0
  19. package/lib/browser-electron/history-store.js +162 -0
  20. package/lib/browser-electron/host-main.js +28 -1
  21. package/lib/browser-electron/provider.d.ts +122 -1
  22. package/lib/browser-electron/provider.js +233 -21
  23. package/lib/browser-electron/remote-host.d.ts +17 -1
  24. package/lib/browser-electron/remote-host.js +81 -9
  25. package/lib/browser-electron/settings-store.d.ts +92 -0
  26. package/lib/browser-electron/settings-store.js +183 -0
  27. package/lib/browser-electron/system-browser.d.ts +100 -0
  28. package/lib/browser-electron/system-browser.js +322 -0
  29. package/lib/browser-electron/virtual-cursor.d.ts +67 -0
  30. package/lib/browser-electron/virtual-cursor.js +141 -0
  31. package/lib/index.d.ts +16 -2
  32. package/lib/index.js +18 -2
  33. package/lib/tool-browser/index.js +122 -16
  34. package/package.json +14 -1
  35. package/src/browser/runtime.ts +22 -0
  36. package/src/browser/types.ts +31 -0
  37. package/src/browser-electron/bridge-connection.ts +145 -0
  38. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  39. package/src/browser-electron/entry.ts +217 -11
  40. package/src/browser-electron/history-store.ts +171 -0
  41. package/src/browser-electron/host-main.ts +26 -1
  42. package/src/browser-electron/provider.ts +284 -21
  43. package/src/browser-electron/remote-host.ts +84 -7
  44. package/src/browser-electron/settings-store.ts +227 -0
  45. package/src/browser-electron/system-browser.ts +318 -0
  46. package/src/browser-electron/virtual-cursor.ts +152 -0
  47. package/src/index.ts +20 -2
  48. package/src/tool-browser/index.ts +121 -15
@@ -10,6 +10,8 @@ import { randomUUID } from 'node:crypto';
10
10
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
13
+ import { HistoryStore } from './history-store.js';
14
+ import { forgetCursor, paintCursor } from './virtual-cursor.js';
13
15
  import { BrowserError } from '../browser/types.js';
14
16
  /**
15
17
  * Page-context human-verification (CAPTCHA / bot-detection) detection. Runs
@@ -135,6 +137,13 @@ export class ElectronBrowserProvider {
135
137
  snapshotMaxElements;
136
138
  contentMaxChars;
137
139
  downloadDir;
140
+ /**
141
+ * Persistent browsing history, or `undefined` when the user turned it off.
142
+ * Sessions come and go; this record outlives all of them.
143
+ */
144
+ historyStore;
145
+ /** Live settings source; absent when nothing owns a settings document. */
146
+ settingsSource;
138
147
  constructor(host, config = {}) {
139
148
  this.host = host;
140
149
  this.httpOnly = config.httpOnly ?? true;
@@ -146,9 +155,21 @@ export class ElectronBrowserProvider {
146
155
  // sandbox does not have to arbitrate. The localized name is probed so a
147
156
  // Chinese desktop (`~/下载`) works without configuring `downloadDir`.
148
157
  this.downloadDir = config.downloadDir ?? defaultDownloadDir();
158
+ this.settingsSource = config.settings;
159
+ // Browsing history follows the browser profile's persistence rules (close
160
+ // the interface, lose nothing), and can be switched off from settings.
161
+ this.historyStore = config.history?.enabled === false
162
+ ? undefined
163
+ : new HistoryStore(config.history?.file, {
164
+ ...config.history?.maxEntries !== undefined ? { maxEntries: config.history.maxEntries } : {},
165
+ ...config.history?.maxAgeDays !== undefined ? { maxAgeDays: config.history.maxAgeDays } : {},
166
+ });
149
167
  // Route toolbar (host UI) actions into the session model: the human and
150
168
  // the agent then always drive the same tabs, history, and navigation.
151
169
  this.host.onUserAction?.(action => { void this.handleUserAction(action); });
170
+ // A window the human closed ends the session it showed: the next call opens a
171
+ // clean one instead of driving a page nobody can see.
172
+ this.host.onViewClosed?.(windowId => { void this.handleViewClosed(windowId); });
152
173
  }
153
174
  /**
154
175
  * Usable when the host says it can back views (the self-hosted host probes
@@ -313,6 +334,7 @@ export class ElectronBrowserProvider {
313
334
  // NEXT observation sees, not whether this action happened).
314
335
  this.record(s, 'navigate', { url }, true);
315
336
  await this.settleDocument(handle, before, signal);
337
+ this.recordVisit(s, handle);
316
338
  this.showActive(s);
317
339
  }
318
340
  catch (error) {
@@ -371,6 +393,11 @@ export class ElectronBrowserProvider {
371
393
  async settleDocument(handle, before, signal) {
372
394
  const started = Date.now();
373
395
  const deadline = started + SETTLE_TIMEOUT_MS;
396
+ // The document is being replaced, so the synthetic pointer drawn into the old one
397
+ // is gone. Dropping the remembered position is what lets it be painted again:
398
+ // without this, a paint at coincidentally identical coordinates would be skipped
399
+ // and the pointer would never come back after a navigation.
400
+ forgetCursor(handle);
374
401
  for (;;) {
375
402
  if (signal?.aborted === true)
376
403
  return;
@@ -848,21 +875,39 @@ export class ElectronBrowserProvider {
848
875
  // iframes stay opaque — the browser forbids reading them).
849
876
  let content = ''
850
877
  if (fmt === 'txt') {
851
- const parts = []
852
- const textWalk = (node) => {
853
- if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
854
- if (node.nodeType !== Node.ELEMENT_NODE) return
855
- const tag = node.tagName.toLowerCase()
856
- if (tag === 'script' || tag === 'style' || tag === 'noscript') return
857
- if (tag === 'iframe') {
858
- try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
859
- return
878
+ // Prefer the browser's own rendered text.
879
+ //
880
+ // Walking text nodes and joining them is not equivalent: pages that wrap
881
+ // every character in its own element — one-character spans for a
882
+ // per-character animation, which is common — produce one text node per
883
+ // letter, and joining those with spaces turns every word into a column of
884
+ // letters. The rendered text reassembles them the way a reader sees them,
885
+ // and it also honours visibility, so hidden text does not leak out.
886
+ // (No backticks in this comment: it lives inside a template literal.)
887
+ const rendered = typeof root.innerText === 'string' ? root.innerText : ''
888
+ if (rendered.trim() !== '') {
889
+ content = rendered
890
+ } else {
891
+ // Fallback for nodes without layout (a detached root, or a fragment):
892
+ // collect text directly, still piercing shadow roots and same-origin
893
+ // iframes, and join without inventing separators beyond what the markup
894
+ // already implies.
895
+ const parts = []
896
+ const textWalk = (node) => {
897
+ if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
898
+ if (node.nodeType !== Node.ELEMENT_NODE) return
899
+ const tag = node.tagName.toLowerCase()
900
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return
901
+ if (tag === 'iframe') {
902
+ try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
903
+ return
904
+ }
905
+ if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
906
+ for (const child of node.childNodes) textWalk(child)
860
907
  }
861
- if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
862
- for (const child of node.childNodes) textWalk(child)
908
+ textWalk(root)
909
+ content = parts.join(' ')
863
910
  }
864
- textWalk(root)
865
- content = parts.join(' ')
866
911
  }
867
912
  else if (fmt === 'html') content = root.outerHTML || ''
868
913
  else if (fmt === 'json') content = JSON.stringify(root)
@@ -930,6 +975,15 @@ export class ElectronBrowserProvider {
930
975
  y = Number(out.y);
931
976
  }
932
977
  else {
978
+ // A coordinate only exists because somebody read a picture. Under the
979
+ // non-visual strategy the plugin must not accept one: a model that cannot
980
+ // see cannot have produced it, so the coordinates are either copied from an
981
+ // earlier vision pass or invented, and honouring them turns a silent
982
+ // mis-click into a mystery. Refusing here, with a message that says what to
983
+ // do instead, converts that into a usable instruction.
984
+ if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
985
+ throw new BrowserError('browser: the configured non-visual strategy refuses coordinate clicks — pass a semantic target instead, e.g. target { by: "text", value: "Sign in" }, which locates the element from the DOM and clicks its centre', 'BROWSER_NON_VISUAL_COORDINATES');
986
+ }
933
987
  x = request.x;
934
988
  y = request.y;
935
989
  }
@@ -951,6 +1005,11 @@ export class ElectronBrowserProvider {
951
1005
  catch (error) {
952
1006
  throw new BrowserError(`browser: click failed: ${String(error)}`, 'BROWSER_CLICK_FAILED', { cause: error });
953
1007
  }
1008
+ // Paint before the press so the ripple is already on screen when the click
1009
+ // lands — the cursor exists to show the human WHERE the agent acts.
1010
+ // Name the operation in the overlay: the pointer is the "agent is driving this
1011
+ // tab" signal, so the bubble is what lets a human follow along without the log.
1012
+ this.showCursor(handle, x, y, 'click', 'click', true);
954
1013
  const release = () => {
955
1014
  void handle
956
1015
  .sendCommand('Input.dispatchMouseEvent', {
@@ -997,7 +1056,11 @@ export class ElectronBrowserProvider {
997
1056
  el.focus()
998
1057
  return { ok: true }
999
1058
  `);
1000
- await this.runTargetScript(this.activeTab(s), script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
1059
+ const locateTab = this.activeTab(s);
1060
+ const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type');
1061
+ const cursorPoint = scriptPoint(out);
1062
+ if (cursorPoint !== undefined)
1063
+ this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true);
1001
1064
  }
1002
1065
  const text = 'text' in request ? request.text : '';
1003
1066
  const timeoutMs = 30_000;
@@ -1067,6 +1130,7 @@ export class ElectronBrowserProvider {
1067
1130
  await withTimeout(handle.sendCommand('Page.navigateToHistoryEntry', { entryId: entry.id }), timeoutMs, signal, `browser: history navigation timed out after ${timeoutMs}ms`, () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
1068
1131
  this.record(s, direction === -1 ? 'back' : 'forward', {}, true);
1069
1132
  await this.settleDocument(handle, before, signal);
1133
+ this.recordVisit(s, handle);
1070
1134
  this.showActive(s);
1071
1135
  }
1072
1136
  /** Go back in the active tab's history. */
@@ -1091,6 +1155,7 @@ export class ElectronBrowserProvider {
1091
1155
  () => { void handle.sendCommand('Page.stopLoading').catch(() => { }); });
1092
1156
  this.record(s, 'reload', {}, true);
1093
1157
  await this.settleDocument(handle, before, signal);
1158
+ this.recordVisit(s, handle);
1094
1159
  this.showActive(s);
1095
1160
  }
1096
1161
  /** Press one named key (Enter/Tab/arrows/…) via CDP key events. */
@@ -1332,7 +1397,18 @@ export class ElectronBrowserProvider {
1332
1397
  if (Date.now() >= deadline) return { ok: false, error: 'element not found: ' + JSON.stringify(spec) + ' (looked for ' + timeoutMs + 'ms)' }
1333
1398
  await sleep(100)
1334
1399
  }
1400
+ const __result = await (async () => {
1335
1401
  ${body}
1402
+ })()
1403
+ // Report the operated element's viewport center next to the script's own
1404
+ // verdict: the synthetic cursor (and any future feedback) needs the point,
1405
+ // and no individual body should have to compute it a second time. Kept
1406
+ // under a distinct key so a body that already returns x/y keeps them.
1407
+ const __rect = el.getBoundingClientRect()
1408
+ return {
1409
+ ...(typeof __result === 'object' && __result !== null ? __result : { ok: true }),
1410
+ __point: { x: Math.round(__rect.x + __rect.width / 2), y: Math.round(__rect.y + __rect.height / 2) },
1411
+ }
1336
1412
  })()`;
1337
1413
  }
1338
1414
  /**
@@ -1397,6 +1473,9 @@ export class ElectronBrowserProvider {
1397
1473
  `;
1398
1474
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1399
1475
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SET_VALUE_FAILED', 'browser: setValue');
1476
+ const cursorPoint = scriptPoint(out);
1477
+ if (cursorPoint !== undefined)
1478
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1400
1479
  this.record(s, 'setValue', { target: request.target, value: String(request.value) }, true, { result: out.method });
1401
1480
  return { method: out.method, value: out.value };
1402
1481
  }
@@ -1422,6 +1501,9 @@ export class ElectronBrowserProvider {
1422
1501
  `;
1423
1502
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1424
1503
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CHECK_FAILED', 'browser: check');
1504
+ const cursorPoint = scriptPoint(out);
1505
+ if (cursorPoint !== undefined)
1506
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1425
1507
  this.record(s, 'check', { target: request.target, checked: want }, true);
1426
1508
  return { checked: out.checked === true };
1427
1509
  }
@@ -1453,6 +1535,9 @@ export class ElectronBrowserProvider {
1453
1535
  `;
1454
1536
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1455
1537
  const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_SELECT_FAILED', 'browser: select');
1538
+ const cursorPoint = scriptPoint(out);
1539
+ if (cursorPoint !== undefined)
1540
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1456
1541
  this.record(s, 'select', { target: request.target, optionValue: request.optionValue ?? null, optionText: request.optionText ?? null, optionIndex: request.optionIndex ?? null }, true);
1457
1542
  return { value: out.value, text: out.text };
1458
1543
  }
@@ -1485,7 +1570,10 @@ export class ElectronBrowserProvider {
1485
1570
  return { ok: true }
1486
1571
  `;
1487
1572
  const script = this.buildTargetScript(request.target, timeoutMs, body);
1488
- await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
1573
+ const out = await this.runTargetScript(tab, script, timeoutMs, signal, 'BROWSER_CLEAR_FAILED', 'browser: clear');
1574
+ const cursorPoint = scriptPoint(out);
1575
+ if (cursorPoint !== undefined)
1576
+ this.showCursor(tab.handle, cursorPoint.x, cursorPoint.y, 'click');
1489
1577
  this.record(s, 'clear', { target: request.target }, true);
1490
1578
  return { cleared: true };
1491
1579
  }
@@ -1775,6 +1863,65 @@ export class ElectronBrowserProvider {
1775
1863
  async history(session) {
1776
1864
  return this.session(session).history;
1777
1865
  }
1866
+ /**
1867
+ * Record one page visit in the persistent browsing history. Fire-and-forget by
1868
+ * design: a visit must never delay or fail the navigation that produced it,
1869
+ * and the title is only worth reading once the document has settled.
1870
+ * @param s - the session that drove the visit.
1871
+ * @param handle - the view whose document just loaded.
1872
+ */
1873
+ recordVisit(s, handle) {
1874
+ const store = this.historyStore;
1875
+ if (store === undefined)
1876
+ return;
1877
+ // The settings panel can switch recording off at runtime; the static config
1878
+ // only supplies the startup default.
1879
+ if (this.settingsSource !== undefined && !this.settingsSource().history.enabled)
1880
+ return;
1881
+ void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
1882
+ .then(result => {
1883
+ if (!result.ok || typeof result.value !== 'string')
1884
+ return;
1885
+ const [url = '', title = ''] = result.value.split('\u0000');
1886
+ // Internal documents are not visits: an untouched browser is not history.
1887
+ if (url === '' || url.startsWith('about:') || url.startsWith('devtools:'))
1888
+ return;
1889
+ store.append({
1890
+ at: Date.now(),
1891
+ url,
1892
+ ...title.trim() !== '' ? { title: title.trim() } : {},
1893
+ ...s.label !== undefined ? { session: s.label } : {},
1894
+ });
1895
+ store.prune();
1896
+ })
1897
+ .catch(() => { });
1898
+ }
1899
+ /**
1900
+ * List persisted visits, newest first — what `browser_visited` reads back.
1901
+ * @param options - result cap and an optional hostname filter.
1902
+ * @returns the matching visits, or an empty list when history is disabled.
1903
+ */
1904
+ visited(options = {}) {
1905
+ return this.historyStore?.list(options) ?? [];
1906
+ }
1907
+ /**
1908
+ * Show the synthetic pointer on a view, unless the user switched it off. The
1909
+ * cursor is the "the agent has taken over this tab" signal, so it is painted
1910
+ * for every operation that has a landing point — including DOM-level ones that
1911
+ * move no real pointer.
1912
+ * @param handle - the view to paint into.
1913
+ * @param x - viewport x in CSS pixels.
1914
+ * @param y - viewport y in CSS pixels.
1915
+ * @param action - click pulses a ripple; move only relocates.
1916
+ * @param label - short description of the operation, shown beside the pointer.
1917
+ * @param force - paint even when the pointer is already there (a click ripple must
1918
+ * always play; a bare move need not repeat).
1919
+ */
1920
+ showCursor(handle, x, y, action, label, force = false) {
1921
+ if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor)
1922
+ return;
1923
+ paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force);
1924
+ }
1778
1925
  /**
1779
1926
  * Replay one recorded operation by sequence number. Navigate/click/type are
1780
1927
  * re-issued against the current page; execute re-runs its script. The
@@ -1857,6 +2004,13 @@ export class ElectronBrowserProvider {
1857
2004
  this.sessions.delete(session);
1858
2005
  for (const tab of existing.tabs)
1859
2006
  this.host.destroyView(tab.handle);
2007
+ // Releasing the browser at session end is a setting, not fixed behaviour
2008
+ // (requirements §3). A self-hosted carrier already tore its window down with
2009
+ // the views above; the desktop's sidebar does not, so it is asked explicitly
2010
+ // — and its cookies plus the browsing history outlive the page either way.
2011
+ if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
2012
+ void this.host.releasePage?.().catch(() => undefined);
2013
+ }
1860
2014
  }
1861
2015
  return Promise.resolve();
1862
2016
  }
@@ -1888,6 +2042,29 @@ export class ElectronBrowserProvider {
1888
2042
  tabByViewId(s, viewId) {
1889
2043
  return s.tabs.find(tab => tab.handle.id === viewId);
1890
2044
  }
2045
+ /**
2046
+ * Whether a session is still live. The human closing a window ends its session
2047
+ * (see {@link handleViewClosed}), so callers that cache ids must ask first.
2048
+ * @param session - the session id to test.
2049
+ */
2050
+ exists(session) {
2051
+ return this.sessions.has(session);
2052
+ }
2053
+ /**
2054
+ * The human closed a browser window: the session that window showed is over.
2055
+ * Ending it here is what makes the next call a clean start rather than a
2056
+ * resurrection of an invisible window. Browsing history and login state live
2057
+ * on disk, so nothing the human cares about is lost with it.
2058
+ * @param windowId - the group key the host reported, which is the session id.
2059
+ */
2060
+ async handleViewClosed(windowId) {
2061
+ if (typeof windowId !== 'string' || windowId === '')
2062
+ return;
2063
+ const s = this.sessions.get(windowId);
2064
+ if (s === undefined)
2065
+ return;
2066
+ await this.close(s.id).catch(() => { });
2067
+ }
1891
2068
  /**
1892
2069
  * Route a user-initiated action from the host's UI into the session model.
1893
2070
  * Fire-and-forget by design: a user action failing (e.g. an unreachable
@@ -1943,19 +2120,47 @@ export class ElectronBrowserProvider {
1943
2120
  this.notifyUserActionError(action, error);
1944
2121
  }
1945
2122
  }
1946
- /** Report a failed user action to the host UI (toolbar), when supported. */
2123
+ /**
2124
+ * Report a failed user action to the host UI (toolbar), when supported.
2125
+ *
2126
+ * Two properties matter here, because this runs inside the catch of an async
2127
+ * handler (issue #16):
2128
+ * - the receiver must be preserved. Reading the method off the host and calling
2129
+ * it unbound runs the host's implementation with `this === undefined`, so its
2130
+ * very first statement (`void this.ready()`) throws, the throw escapes the
2131
+ * async catch as an unhandled rejection, and the whole DSH host exits;
2132
+ * - this method must be incapable of throwing. Reporting a failed action is
2133
+ * diagnostics: it can never be allowed to become the failure.
2134
+ */
1947
2135
  notifyUserActionError(action, error) {
1948
- const notify = this.host.notifyUserActionError;
1949
2136
  const message = `action ${action.type} failed: ${String(error)}`;
1950
- if (typeof notify === 'function') {
1951
- notify(action.windowId, message);
2137
+ let reported = false;
2138
+ try {
2139
+ const host = this.host;
2140
+ const notify = host.notifyUserActionError;
2141
+ if (typeof notify === 'function') {
2142
+ // Called on its owner: an unbound call here kills the host process.
2143
+ notify.call(host, action.windowId, message);
2144
+ reported = true;
2145
+ }
1952
2146
  }
1953
- else {
1954
- process.stderr.write(`[dsh-browser] user action failed: ${message}\n`);
2147
+ catch (notifyError) {
2148
+ // Contained on purpose; the log line below still carries the original cause.
2149
+ process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`);
1955
2150
  }
2151
+ if (!reported)
2152
+ process.stderr.write(`[dsh-browser] user action failed: ${message}\n`);
1956
2153
  }
1957
2154
  /** Ask the host to show the active tab's view, carrying the session label. */
1958
2155
  showActive(s) {
2156
+ // §3: whether the agent's page takes over the screen is the user's choice, not
2157
+ // a fixed behaviour. With auto-expand off the page still exists and is still
2158
+ // driven — it is simply folded away, so the agent works without claiming the
2159
+ // sidebar. Carriers that own no presentation (self-hosted) ignore the call.
2160
+ if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
2161
+ void this.host.collapse?.().catch(() => undefined);
2162
+ return;
2163
+ }
1959
2164
  this.host.showView?.(this.activeTab(s).handle, s.label);
1960
2165
  }
1961
2166
  /** Read the current URL of a view through CDP. */
@@ -2079,6 +2284,13 @@ function terminatePage(handle) {
2079
2284
  * @param expression - the JS expression.
2080
2285
  * @param signal - optional abort signal; a fired signal rejects the call.
2081
2286
  */
2287
+ /** Viewport point a target script reported, when it reported one. */
2288
+ function scriptPoint(out) {
2289
+ const point = out?.__point;
2290
+ const x = Number(point?.x);
2291
+ const y = Number(point?.y);
2292
+ return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined;
2293
+ }
2082
2294
  async function handleSendEvaluate(handle, expression, signal) {
2083
2295
  signal?.throwIfAborted();
2084
2296
  const result = await handle.sendCommand(CDP_RUNTIME_EVALUATE, {
@@ -118,6 +118,12 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
118
118
  private readonly groups;
119
119
  /** The provider's user-action handler; routes toolbar actions into sessions. */
120
120
  private userActionHandler;
121
+ /**
122
+ * The provider's handler for a window the human closed. Closing the interface
123
+ * ends the session it showed: history and login state stay on disk, so the
124
+ * next open starts clean instead of resuming a window nobody can see.
125
+ */
126
+ private viewClosedHandler;
121
127
  constructor(hostMainPath: string,
122
128
  /** Test seam: the executable to spawn instead of the resolved Electron
123
129
  * binary. Absent -> resolveElectronPath() (production behavior). */
@@ -139,7 +145,15 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
139
145
  * on a cooldown heals it by itself while keeping the scan off the hot path.
140
146
  */
141
147
  available(): boolean;
142
- /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
148
+ /**
149
+ * Ensure the child is up and ready (lazy on first use; restarts after a crash).
150
+ *
151
+ * Never throws synchronously. Callers are fire-and-forget
152
+ * (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
153
+ * `.catch` entirely — the shape that let a failed toolbar report take the whole
154
+ * DSH host down (issue #16). A disposed host is reported as a rejected promise
155
+ * instead, which those callers already handle.
156
+ */
143
157
  private ready;
144
158
  private start;
145
159
  /** The child died: tear down so the next use starts a fresh child. */
@@ -167,6 +181,8 @@ export declare class RemoteElectronViewHost implements ElectronBrowserViewHost {
167
181
  groupView(handle: ElectronViewHandle, windowId: string, label?: string): void;
168
182
  /** Register the provider's handler for user-initiated toolbar actions. */
169
183
  onUserAction(handler: (action: BrowserUserAction) => void): void;
184
+ /** Register the provider's handler for windows the human closed. */
185
+ onViewClosed(handler: (windowId: string) => void): void;
170
186
  /** Surface a failed user action to the child's toolbar (address bar etc.). */
171
187
  notifyUserActionError(windowId: string, message: string): void;
172
188
  destroyView(handle: ElectronViewHandle): void;
@@ -374,6 +374,7 @@ class ElectronChildClient {
374
374
  token;
375
375
  onExit;
376
376
  onUserAction;
377
+ onViewClosed;
377
378
  executable;
378
379
  child;
379
380
  pending = new Map();
@@ -392,6 +393,12 @@ class ElectronChildClient {
392
393
  awaitingHello = true;
393
394
  helloTimer;
394
395
  constructor(hostMainPath, port, token, onExit, onUserAction,
396
+ /**
397
+ * Called when the human closes a browser window. The session it showed is
398
+ * over: the provider releases it, while history and login state stay on disk
399
+ * so the next open is a clean session rather than a resurrected one.
400
+ */
401
+ onViewClosed,
395
402
  /** Test seam: the executable to spawn instead of the resolved Electron
396
403
  * binary. Absent -> resolveElectronPath() (production behavior). */
397
404
  executable) {
@@ -400,9 +407,18 @@ class ElectronChildClient {
400
407
  this.token = token;
401
408
  this.onExit = onExit;
402
409
  this.onUserAction = onUserAction;
410
+ this.onViewClosed = onViewClosed;
403
411
  this.executable = executable;
404
412
  const electron = this.executable ?? resolveElectronPath();
413
+ // Record what is about to be spawned — with existence checks — BEFORE the
414
+ // attempt. When Electron cannot load the app entry it exits 1 with EMPTY
415
+ // stderr, and that signature is otherwise indistinguishable from a missing
416
+ // binary: writing both paths (and whether they exist) in advance is what
417
+ // turns the next occurrence into a named cause instead of a guess.
405
418
  process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`);
419
+ appendHostLog(`${hostStamp()} spawning: electron=${electron} (exists=${String(existsSync(electron))})`
420
+ + ` hostMain=${this.hostMainPath} (exists=${String(existsSync(this.hostMainPath))})`
421
+ + ` port=${String(this.port)}\n`);
406
422
  // ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
407
423
  // Node, breaking require('electron'); NODE_OPTIONS can inject flags that
408
424
  // break the child. Rebuild the env without either.
@@ -439,15 +455,26 @@ class ElectronChildClient {
439
455
  // A failed spawn (bad/corrupt binary) emits 'error' — without a listener
440
456
  // that would crash the whole DSH process.
441
457
  this.child.on('error', error => {
442
- const line = `spawn error: ${String(error)}`;
458
+ const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`;
443
459
  process.stderr.write(`[dsh-browser host] ${line}\n`);
444
460
  appendHostLog(`${line}\n`);
445
461
  this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`));
446
462
  });
447
463
  this.child.on('exit', (code, signal) => {
448
464
  // The exit code/signal is the other half of a crash report; without it a
449
- // restart loop is indistinguishable from a clean stop.
450
- const line = `browser host exited (code=${String(code)} signal=${String(signal)})`;
465
+ // restart loop is indistinguishable from a clean stop. The timestamp, pid
466
+ // and both paths are what turn a bare `code=1` into a diagnosis: Electron
467
+ // exits 1 with empty stderr precisely when it cannot load the app entry,
468
+ // so the dated `exists=` line written at spawn is the other half.
469
+ const entryExists = existsSync(this.hostMainPath);
470
+ const line = `${hostStamp()} browser host exited (code=${String(code)} signal=${String(signal)})`
471
+ + ` pid=${String(this.child?.pid)} electron=${electron} hostMain=${this.hostMainPath}`
472
+ + ` entryExists=${String(entryExists)}`
473
+ // Comparing this with the `exists=` recorded at spawn is the whole
474
+ // diagnosis: present at spawn and absent now means the installation was
475
+ // replaced underneath a running host, which is exactly how Electron ends
476
+ // up unable to load the app entry and exits 1 with empty stderr.
477
+ + (entryExists ? '' : ' [entry script missing at exit]');
451
478
  appendHostLog(`${line}\n`);
452
479
  this.fail(new Error(`dsh-builtin-browser: ${line}`));
453
480
  });
@@ -537,6 +564,14 @@ class ElectronChildClient {
537
564
  this.onUserAction?.(msg.action);
538
565
  continue;
539
566
  }
567
+ if (msg.op === 'viewClosed') {
568
+ // Fire-and-forget notification from the child's own UI: the human closed
569
+ // the window, so the interface that session showed is gone. The provider
570
+ // decides what that ends — browsing history and login state survive on
571
+ // disk, only the session itself does not.
572
+ this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '');
573
+ continue;
574
+ }
540
575
  if (this.awaitingHello) {
541
576
  // The FIRST line must be the child's hello carrying the spawn token.
542
577
  // Anything else (or a wrong token) means a spoofed connection: drop
@@ -681,6 +716,12 @@ export class RemoteElectronViewHost {
681
716
  groups = new Map();
682
717
  /** The provider's user-action handler; routes toolbar actions into sessions. */
683
718
  userActionHandler;
719
+ /**
720
+ * The provider's handler for a window the human closed. Closing the interface
721
+ * ends the session it showed: history and login state stay on disk, so the
722
+ * next open starts clean instead of resuming a window nobody can see.
723
+ */
724
+ viewClosedHandler;
684
725
  constructor(hostMainPath,
685
726
  /** Test seam: the executable to spawn instead of the resolved Electron
686
727
  * binary. Absent -> resolveElectronPath() (production behavior). */
@@ -724,13 +765,27 @@ export class RemoteElectronViewHost {
724
765
  }
725
766
  return this.electronAvailable;
726
767
  }
727
- /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
768
+ /**
769
+ * Ensure the child is up and ready (lazy on first use; restarts after a crash).
770
+ *
771
+ * Never throws synchronously. Callers are fire-and-forget
772
+ * (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
773
+ * `.catch` entirely — the shape that let a failed toolbar report take the whole
774
+ * DSH host down (issue #16). A disposed host is reported as a rejected promise
775
+ * instead, which those callers already handle.
776
+ */
728
777
  ready() {
729
778
  // Fail fast on a disposed host: a recovery path can re-enter ready() after
730
779
  // dispose() cleared its state, and starting fresh here would spawn a child
731
780
  // nobody will ever kill.
732
- if (this.disposed)
733
- throw new HostGoneError('browser host disposed');
781
+ if (this.disposed) {
782
+ const gone = Promise.reject(new HostGoneError('browser host disposed'));
783
+ // Keep the rejection observable to callers while marking it handled here:
784
+ // a fire-and-forget caller that forgot to attach a handler must not turn
785
+ // this into an unhandled rejection either.
786
+ gone.catch(() => undefined);
787
+ return gone;
788
+ }
734
789
  if (this.readyPromise !== undefined)
735
790
  return this.readyPromise;
736
791
  const started = this.start();
@@ -787,7 +842,7 @@ export class RemoteElectronViewHost {
787
842
  // Random per-spawn token: the child must prove knowledge of it (via
788
843
  // stdin hello) before any command is written to the socket.
789
844
  const token = randomBytes(24).toString('hex');
790
- this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), this.spawnExecutable);
845
+ this.client = new ElectronChildClient(this.hostMainPath, port, token, () => this.onChildExit(), action => this.userActionHandler?.(action), windowId => this.viewClosedHandler?.(windowId), this.spawnExecutable);
791
846
  if (this.pendingSocket !== undefined) {
792
847
  this.client.attach(this.pendingSocket);
793
848
  this.pendingSocket = undefined;
@@ -916,6 +971,10 @@ export class RemoteElectronViewHost {
916
971
  onUserAction(handler) {
917
972
  this.userActionHandler = handler;
918
973
  }
974
+ /** Register the provider's handler for windows the human closed. */
975
+ onViewClosed(handler) {
976
+ this.viewClosedHandler = handler;
977
+ }
919
978
  /** Surface a failed user action to the child's toolbar (address bar etc.). */
920
979
  notifyUserActionError(windowId, message) {
921
980
  void this.ready()
@@ -1028,6 +1087,15 @@ class DeferredRemoteView {
1028
1087
  * gets large (a crash loop writes quickly).
1029
1088
  */
1030
1089
  const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024;
1090
+ /**
1091
+ * ISO timestamp prefix for host-log lines. This log is the only place a spawn
1092
+ * failure is recorded, and an undated line cannot be lined up with anything else
1093
+ * that happened on the machine — a plugin update replacing the entry script, for
1094
+ * instance — which is precisely the question this log gets asked.
1095
+ */
1096
+ function hostStamp() {
1097
+ return new Date().toISOString();
1098
+ }
1031
1099
  function appendHostLog(text) {
1032
1100
  const home = process.env.DSH_HOME;
1033
1101
  if (home === undefined || home === '')
@@ -1036,8 +1104,12 @@ function appendHostLog(text) {
1036
1104
  const dir = join(home, 'logs');
1037
1105
  const file = join(dir, 'dsh-builtin-browser-host.log');
1038
1106
  try {
1039
- if (statSync(file).size > HOST_LOG_MAX_BYTES)
1040
- writeFileSync(file, '');
1107
+ if (statSync(file).size > HOST_LOG_MAX_BYTES) {
1108
+ // Discard the old bytes but leave a dated marker: truncating to empty
1109
+ // makes a rotation look like "nothing ever happened", which is how the
1110
+ // history of a crash loop gets silently swallowed.
1111
+ writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`);
1112
+ }
1041
1113
  }
1042
1114
  catch { /* first write, or unreadable: just append */ }
1043
1115
  mkdirSync(dir, { recursive: true });