dsh-builtin-browser 0.4.6 → 0.4.8

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/CHANGELOG.md +76 -0
  2. package/README.en.md +62 -491
  3. package/README.md +44 -470
  4. package/client.js +10 -10
  5. package/cordis.patch.yml +4 -4
  6. package/desktop-bridge/plugin-browser-bridge.js +1 -1
  7. package/docs/README.md +8 -4
  8. package/docs/architecture.md +82 -11
  9. package/docs/development.md +38 -0
  10. package/docs/limitations.md +49 -0
  11. package/docs/tool-reference.md +42 -42
  12. package/docs/user-guide.md +93 -37
  13. package/docs/why-browser.md +3 -3
  14. package/lib/browser/types.d.ts +2 -2
  15. package/lib/browser-electron/bridge-connection.d.ts +7 -1
  16. package/lib/browser-electron/bridge-connection.js +58 -3
  17. package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
  18. package/lib/browser-electron/desktop-bridge-host.js +21 -1
  19. package/lib/browser-electron/entry.d.ts +2 -2
  20. package/lib/browser-electron/entry.js +186 -103
  21. package/lib/browser-electron/history-store.d.ts +1 -1
  22. package/lib/browser-electron/history-store.js +1 -1
  23. package/lib/browser-electron/host-main.js +3 -3
  24. package/lib/browser-electron/provider.d.ts +32 -8
  25. package/lib/browser-electron/provider.js +97 -16
  26. package/lib/browser-electron/remote-host.js +1 -1
  27. package/lib/browser-electron/settings-store.d.ts +14 -6
  28. package/lib/browser-electron/settings-store.js +29 -9
  29. package/lib/index.d.ts +1 -1
  30. package/lib/index.js +1 -1
  31. package/lib/tool-browser/element-fields.d.ts +4 -4
  32. package/lib/tool-browser/element-fields.js +6 -6
  33. package/lib/tool-browser/index.d.ts +6 -6
  34. package/lib/tool-browser/index.js +196 -139
  35. package/package.json +2 -2
  36. package/scripts/verify-host-compat.mjs +6 -6
  37. package/src/browser/types.ts +2 -2
  38. package/src/browser-electron/bridge-connection.ts +61 -3
  39. package/src/browser-electron/desktop-bridge-host.ts +22 -1
  40. package/src/browser-electron/entry.ts +101 -9
  41. package/src/browser-electron/history-store.ts +1 -1
  42. package/src/browser-electron/host-main.ts +3 -3
  43. package/src/browser-electron/provider.ts +135 -31
  44. package/src/browser-electron/remote-host.ts +1 -1
  45. package/src/browser-electron/settings-store.ts +32 -13
  46. package/src/index.ts +1 -1
  47. package/src/tool-browser/element-fields.ts +6 -6
  48. package/src/tool-browser/index.ts +197 -140
@@ -99,7 +99,7 @@ export function layoutSize(metrics) {
99
99
  return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height };
100
100
  }
101
101
  /**
102
- * Map CDP cookies onto the shape `browser_auth` exports.
102
+ * Map CDP cookies onto the shape `dsh_browser_auth` exports.
103
103
  *
104
104
  * CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
105
105
  * milliseconds, so both are converted. Entries missing a name, value or domain are
@@ -352,7 +352,7 @@ export class ElectronBrowserProvider {
352
352
  // session), carrying the label for the window title. Hosts without
353
353
  // groupView keep the shared-window behavior.
354
354
  this.host.groupView?.(handle, id, label);
355
- this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 });
355
+ this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 });
356
356
  return Promise.resolve(id);
357
357
  }
358
358
  /** Open a URL in the active tab (default) or a new tab. */
@@ -371,9 +371,16 @@ export class ElectronBrowserProvider {
371
371
  const tab = s.tabs[i];
372
372
  if (tab === undefined)
373
373
  continue; // defensive: array can shift under concurrency
374
+ // A tab we know has never navigated is NOT probed: its view has committed no document, so
375
+ // the round trip can only answer nothing — and it is the round trip that costs. A tab that
376
+ // HAS navigated is re-read, because the page may have moved itself since; a read that fails
377
+ // keeps the last accepted URL rather than reporting the tab as blank.
378
+ const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url);
379
+ if (liveUrl !== '')
380
+ tab.url = liveUrl;
374
381
  result.push({
375
382
  id: tab.id,
376
- url: await this.currentUrl(tab.handle).catch(() => ''),
383
+ url: liveUrl,
377
384
  active: i === s.activeIndex,
378
385
  });
379
386
  }
@@ -455,7 +462,8 @@ export class ElectronBrowserProvider {
455
462
  /** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
456
463
  async navigate(session, request, signal) {
457
464
  const s = this.session(session);
458
- const { handle } = this.activeTab(s);
465
+ const tab = this.activeTab(s);
466
+ const { handle } = tab;
459
467
  const url = request.url;
460
468
  try {
461
469
  if (this.httpOnly) {
@@ -496,6 +504,11 @@ export class ElectronBrowserProvider {
496
504
  // the visit written to the persistent history for a navigation nobody awaits any more.
497
505
  signal?.throwIfAborted();
498
506
  this.record(s, 'navigate', { url }, true);
507
+ // The tab now holds a committed document, which is what the blank short-circuit in
508
+ // `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
509
+ // navigation HAS committed, and a tab wrongly treated as blank would return empty results
510
+ // for a page that is really there.
511
+ tab.url = url;
499
512
  await this.settleDocument(handle, before, signal);
500
513
  this.recordVisit(s, handle);
501
514
  this.showActive(s);
@@ -543,9 +556,9 @@ export class ElectronBrowserProvider {
543
556
  /**
544
557
  * Page.navigate resolves at navigation COMMIT, not at load: the new document
545
558
  * is already current (so location.href and document.title are the new page's)
546
- * while its DOM is still being parsed. browser_open snapshots immediately
559
+ * while its DOM is still being parsed. dsh_browser_open snapshots immediately
547
560
  * after navigating, which is why it could report the right title with zero
548
- * interactive elements — and why a separate browser_snapshot right after
561
+ * interactive elements — and why a separate dsh_browser_snapshot right after
549
562
  * always found them.
550
563
  *
551
564
  * Wait — bounded, best-effort — for the new document to settle: readyState
@@ -653,7 +666,7 @@ export class ElectronBrowserProvider {
653
666
  *
654
667
  * A renderer only delivers key events to the view that holds web focus. A
655
668
  * view that was created but never clicked holds none — and `Input` events
656
- * injected over CDP do not grant it — so the FIRST `browser_key` of a fresh
669
+ * injected over CDP do not grant it — so the FIRST `dsh_browser_key` of a fresh
657
670
  * session vanished inside the renderer while the command still answered
658
671
  * success. Hosts that cannot focus a view (a test double, a different shell
659
672
  * adapter) simply have no `focus`, and nothing changes for them.
@@ -663,7 +676,7 @@ export class ElectronBrowserProvider {
663
676
  return;
664
677
  try {
665
678
  // Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
666
- // and this runs before typing, so an unresponsive child would hang browser_key for
679
+ // and this runs before typing, so an unresponsive child would hang dsh_browser_key for
667
680
  // good. A timeout is caught below exactly like any other focus failure, so the
668
681
  // best-effort contract is unchanged.
669
682
  await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out');
@@ -808,6 +821,11 @@ export class ElectronBrowserProvider {
808
821
  const s = this.session(session);
809
822
  const tab = this.activeTab(s);
810
823
  signal?.throwIfAborted();
824
+ // A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
825
+ // it — there is no document to commit — so the call would sit out its whole budget before
826
+ // saying anything. Answering the empty page immediately is both faster and more truthful.
827
+ if (tab.url === '')
828
+ return { url: '', elements: [], truncated: false };
811
829
  const script = `(() => {
812
830
  const cap = ${String(this.snapshotMaxElements)}
813
831
  const url = location.href
@@ -880,7 +898,22 @@ export class ElectronBrowserProvider {
880
898
  // Same hang guard as execute: a renderer that has not committed after
881
899
  // navigate would otherwise block snapshot forever.
882
900
  const timeoutMs = 30_000;
883
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
901
+ let result;
902
+ try {
903
+ result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
904
+ }
905
+ catch (error) {
906
+ // A timeout leaves the wedged view in the session, so every later read would pay the same
907
+ // 30 seconds again — and `terminatePage` only interrupts the running script, it does not
908
+ // make the view readable. Replacing it is what turns a permanent loss into one bad call:
909
+ // the tab keeps the id the caller holds, and the next dsh_browser_open with the real URL
910
+ // repopulates it.
911
+ if (error instanceof Error && error.name === 'TimeoutError') {
912
+ this.replaceTabWithBlank(s, tab);
913
+ throw new BrowserError('browser: snapshot timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.', 'BROWSER_SNAPSHOT_TIMEOUT', { cause: error });
914
+ }
915
+ throw error;
916
+ }
884
917
  if (!result.ok)
885
918
  throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
886
919
  const value = result.value;
@@ -898,6 +931,10 @@ export class ElectronBrowserProvider {
898
931
  const s = this.session(session);
899
932
  const tab = this.activeTab(s);
900
933
  signal?.throwIfAborted();
934
+ // Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
935
+ // the tree cannot be read and the call would spend its whole budget discovering that.
936
+ if (tab.url === '')
937
+ return { url: '', title: undefined, count: 0, nodes: [], truncated: false };
901
938
  const includeHidden = request.includeHidden === true;
902
939
  // 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
903
940
  // characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
@@ -1059,7 +1096,19 @@ export class ElectronBrowserProvider {
1059
1096
  return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
1060
1097
  })()`;
1061
1098
  const timeoutMs = 30_000;
1062
- const result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1099
+ let result;
1100
+ try {
1101
+ result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
1102
+ }
1103
+ catch (error) {
1104
+ // As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
1105
+ // charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
1106
+ if (error instanceof Error && error.name === 'TimeoutError') {
1107
+ this.replaceTabWithBlank(s, tab);
1108
+ throw new BrowserError('browser: a11y timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.', 'BROWSER_A11Y_TIMEOUT', { cause: error });
1109
+ }
1110
+ throw error;
1111
+ }
1063
1112
  if (!result.ok)
1064
1113
  throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED');
1065
1114
  const value = result.value;
@@ -1468,7 +1517,7 @@ export class ElectronBrowserProvider {
1468
1517
  placeholder: f.placeholder ?? null,
1469
1518
  // A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
1470
1519
  // it to 'text' here made `if (spec.kind)` above always true, so every unspecified
1471
- // field was narrowed to an <input> — browser_fill on a <textarea> reported
1520
+ // field was narrowed to an <input> — dsh_browser_fill on a <textarea> reported
1472
1521
  // `no "text" control matched`, and a contenteditable could not match any kind at all.
1473
1522
  // The value stays a string or null (never undefined): the in-page check tests truthiness,
1474
1523
  // and JSON drops an undefined member entirely, which is the same thing here.
@@ -1603,7 +1652,7 @@ export class ElectronBrowserProvider {
1603
1652
  el.dispatchEvent(new Event('change', { bubbles: true }))
1604
1653
  out.push({ ok: true, method: 'select', target: describe(spec) })
1605
1654
  } else if (type === 'file') {
1606
- out.push({ ok: false, error: 'file inputs cannot be set from script; use browser_download or ask the human', target: describe(spec) })
1655
+ out.push({ ok: false, error: 'file inputs cannot be set from script; use dsh_browser_download or ask the human', target: describe(spec) })
1607
1656
  } else if (type === 'checkbox') {
1608
1657
  const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
1609
1658
  if (el.checked !== want) el.click()
@@ -2031,7 +2080,7 @@ export class ElectronBrowserProvider {
2031
2080
  * Refuse an action the settings have switched off.
2032
2081
  *
2033
2082
  * These switches belong to the OPERATOR. The settings document is written through the
2034
- * plugin's own panel and no tool can reach it, so — unlike `browser_restrict`, which the
2083
+ * plugin's own panel and no tool can reach it, so — unlike `dsh_browser_restrict`, which the
2035
2084
  * model owns and can lift at will — a refusal here stands for as long as the setting does.
2036
2085
  * That is the point of them: a deployment can take page-script execution, downloads, or
2037
2086
  * login-state writes off the table without relying on the model's cooperation.
@@ -2058,7 +2107,7 @@ export class ElectronBrowserProvider {
2058
2107
  }
2059
2108
  /**
2060
2109
  * Admit a caller-supplied save path for a file the browser writes to disk.
2061
- * ONE gate for both `browser_download` and `browser_screenshot`: the path
2110
+ * ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
2062
2111
  * must be absolute, must resolve inside `downloadDir`, and must not already
2063
2112
  * exist. Without it a prompt-injected agent could write — or silently
2064
2113
  * replace — any file the DSH process can reach, which also escapes the file
@@ -2440,7 +2489,7 @@ export class ElectronBrowserProvider {
2440
2489
  .catch(() => { });
2441
2490
  }
2442
2491
  /**
2443
- * List persisted visits, newest first — what `browser_visited` reads back.
2492
+ * List persisted visits, newest first — what `dsh_browser_visited` reads back.
2444
2493
  * @param options - result cap and an optional hostname filter.
2445
2494
  * @returns the matching visits, or an empty list when history is disabled.
2446
2495
  */
@@ -2579,10 +2628,42 @@ export class ElectronBrowserProvider {
2579
2628
  const handle = this.host.createView(s.label);
2580
2629
  // Same window as the session's other tabs.
2581
2630
  this.host.groupView?.(handle, s.id, s.label);
2582
- s.tabs.push({ id: `tab:${randomUUID()}`, handle });
2631
+ s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' });
2583
2632
  s.activeIndex = s.tabs.length - 1;
2584
2633
  this.showActive(s);
2585
2634
  }
2635
+ /**
2636
+ * Swap a wedged view for a fresh blank one, keeping the tab's public id.
2637
+ *
2638
+ * A view whose renderer never commits cannot be read again: every evaluate into it runs out its
2639
+ * whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
2640
+ * callers hold stays valid, and the next `dsh_browser_open` with the real URL re-populates it.
2641
+ *
2642
+ * The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
2643
+ * session holding a handle that is already gone.
2644
+ * @param s - the session owning the tab.
2645
+ * @param tab - the tab to reset.
2646
+ */
2647
+ replaceTabWithBlank(s, tab) {
2648
+ const index = s.tabs.indexOf(tab);
2649
+ if (index < 0)
2650
+ return;
2651
+ let handle;
2652
+ try {
2653
+ handle = this.host.createView(s.label);
2654
+ this.host.groupView?.(handle, s.id, s.label);
2655
+ }
2656
+ catch {
2657
+ // Creating the replacement failed: leaving the wedged tab in place is worse than nothing
2658
+ // only for the caller, who is about to be told the tab timed out either way.
2659
+ return;
2660
+ }
2661
+ const wedged = tab.handle;
2662
+ s.tabs[index] = { id: tab.id, handle, url: '' };
2663
+ if (index === s.activeIndex)
2664
+ this.showActive(s);
2665
+ this.host.destroyView(wedged);
2666
+ }
2586
2667
  /** Find a session's tab by its backing view id (toolbar actions carry view ids). */
2587
2668
  tabByViewId(s, viewId) {
2588
2669
  return s.tabs.find(tab => tab.handle.id === viewId);
@@ -1275,7 +1275,7 @@ class DeferredRemoteView {
1275
1275
  * with "browser host is not running". Instead: drop the cache, rebuild the
1276
1276
  * view against a freshly spawned child, and retry the operation exactly
1277
1277
  * once — so sessions that survive a host death self-heal on their FIRST
1278
- * call after it, without a manual browser_reset_session. Page-level errors
1278
+ * call after it, without a manual dsh_browser_reset_session. Page-level errors
1279
1279
  * are never retried (the view itself is fine).
1280
1280
  */
1281
1281
  async withRecovery(op) {
@@ -49,15 +49,15 @@ export interface BrowserSettings {
49
49
  *
50
50
  * These are OPERATOR switches: the settings panel is not reachable from the model, so a
51
51
  * value here refuses whatever the model asks for, and no tool can lift it. That is what
52
- * separates them from `browser_restrict`, which is the model's own soft guardrail. All
52
+ * separates them from `dsh_browser_restrict`, which is the model's own soft guardrail. All
53
53
  * three default to on, which is the documented out-of-the-box behaviour.
54
54
  */
55
55
  readonly actions: {
56
- /** Run arbitrary page scripts in the active tab (`browser_execute`). */
56
+ /** Run arbitrary page scripts in the active tab (`dsh_browser_execute`). */
57
57
  readonly allowExecute: boolean;
58
- /** Download a URL to disk (`browser_download`). */
58
+ /** Download a URL to disk (`dsh_browser_download`). */
59
59
  readonly allowDownload: boolean;
60
- /** Write cookies — importing login state (`browser_auth` restore). */
60
+ /** Write cookies — importing login state (`dsh_browser_auth` restore). */
61
61
  readonly allowCredentialWrite: boolean;
62
62
  };
63
63
  }
@@ -69,6 +69,13 @@ export declare const DEFAULT_SETTINGS: BrowserSettings;
69
69
  * @returns the settings file path.
70
70
  */
71
71
  export declare function settingsPath(): string;
72
+ /**
73
+ * The channels a settings document may name.
74
+ *
75
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
76
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
77
+ */
78
+ export declare const BROWSER_CHANNELS: readonly ["chrome", "edge", "auto", "bundled", "sidebar"];
72
79
  /**
73
80
  * Resolve a raw settings document (already parsed, shape unknown) into the full
74
81
  * document, field by field. Unknown keys are dropped: the file is not a place to
@@ -76,7 +83,7 @@ export declare function settingsPath(): string;
76
83
  * @param raw - the parsed file contents.
77
84
  * @returns the resolved settings.
78
85
  */
79
- export declare function resolveSettings(raw: unknown): BrowserSettings;
86
+ export declare function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings;
80
87
  /**
81
88
  * File-backed settings with an in-memory snapshot. Reads re-stat the file so an
82
89
  * edit from another process (or the settings panel writing through a second
@@ -85,13 +92,14 @@ export declare function resolveSettings(raw: unknown): BrowserSettings;
85
92
  */
86
93
  export declare class SettingsStore {
87
94
  private readonly file;
95
+ private readonly log?;
88
96
  private cached;
89
97
  /** Last observed mtime, used to invalidate the cache on external edits. */
90
98
  private cachedMtimeMs;
91
99
  /**
92
100
  * @param file - settings file path (defaults beside the browser profile).
93
101
  */
94
- constructor(file?: string);
102
+ constructor(file?: string, log?: ((message: string) => void) | undefined);
95
103
  /** Path this store reads and writes. */
96
104
  path(): string;
97
105
  /**
@@ -68,16 +68,34 @@ const SETTINGS_WHEN_UNREADABLE = {
68
68
  credentials: { allowRead: false },
69
69
  actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
70
70
  };
71
+ /**
72
+ * The channels a settings document may name.
73
+ *
74
+ * Exported so the log line and the error message can quote the same set the parser accepts: a
75
+ * message that lists a value the code rejects (or omits one it takes) is worse than no message.
76
+ */
77
+ export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'];
71
78
  /**
72
79
  * Coerce the browser channel, keeping the default when it is not one of the
73
80
  * known choices. A hand-edited typo must not silently select a different browser.
81
+ *
82
+ * Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
83
+ * the same to whoever typed it, which is why every discarded value is reported.
74
84
  * @param value - the raw value from the settings document.
85
+ * @param warn - where to report a discarded value, when the caller has somewhere to put it.
75
86
  * @returns a valid channel.
76
87
  */
77
- function resolveChannel(value) {
78
- return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
79
- ? value
80
- : DEFAULT_SETTINGS.browser.channel;
88
+ function resolveChannel(value, warn) {
89
+ // Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
90
+ // the default without a word.
91
+ if (value === undefined)
92
+ return DEFAULT_SETTINGS.browser.channel;
93
+ if (BROWSER_CHANNELS.includes(value))
94
+ return value;
95
+ warn?.(`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
96
+ + `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
97
+ + `${BROWSER_CHANNELS.join(', ')}.`);
98
+ return DEFAULT_SETTINGS.browser.channel;
81
99
  }
82
100
  /** Coerce one positive-integer field, keeping the default when invalid. */
83
101
  function count(value, fallback) {
@@ -119,7 +137,7 @@ function section(source, key) {
119
137
  * @param raw - the parsed file contents.
120
138
  * @returns the resolved settings.
121
139
  */
122
- export function resolveSettings(raw) {
140
+ export function resolveSettings(raw, warn) {
123
141
  const source = typeof raw === 'object' && raw !== null ? raw : {};
124
142
  const history = section(source, 'history');
125
143
  const cookies = section(source, 'cookies');
@@ -146,7 +164,7 @@ export function resolveSettings(raw) {
146
164
  virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
147
165
  },
148
166
  vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
149
- browser: { channel: resolveChannel(browser?.channel) },
167
+ browser: { channel: resolveChannel(browser?.channel, warn) },
150
168
  credentials: {
151
169
  // `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
152
170
  // is refused here for free: the section itself is the "present but wrong" value.
@@ -175,14 +193,16 @@ export function resolveSettings(raw) {
175
193
  */
176
194
  export class SettingsStore {
177
195
  file;
196
+ log;
178
197
  cached;
179
198
  /** Last observed mtime, used to invalidate the cache on external edits. */
180
199
  cachedMtimeMs = -1;
181
200
  /**
182
201
  * @param file - settings file path (defaults beside the browser profile).
183
202
  */
184
- constructor(file = settingsPath()) {
203
+ constructor(file = settingsPath(), log) {
185
204
  this.file = file;
205
+ this.log = log;
186
206
  }
187
207
  /** Path this store reads and writes. */
188
208
  path() {
@@ -230,7 +250,7 @@ export class SettingsStore {
230
250
  // value in it: the document looked correct, and the plugin quietly ran on
231
251
  // defaults — including switches the user had just turned off.
232
252
  const body = text?.replace(/^\uFEFF/, '');
233
- this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
253
+ this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m));
234
254
  }
235
255
  catch {
236
256
  // A document that EXISTS but cannot be parsed is not the same as no document at all: the
@@ -250,7 +270,7 @@ export class SettingsStore {
250
270
  */
251
271
  update(patch) {
252
272
  const current = this.get();
253
- const merged = resolveSettings(deepMerge(current, patch));
273
+ const merged = resolveSettings(deepMerge(current, patch), m => this.log?.(m));
254
274
  try {
255
275
  mkdirSync(dirname(this.file), { recursive: true });
256
276
  writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
package/lib/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * pieces. The cordis.patch.yml rows reference subpath exports:
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
7
7
  * The capability itself is composed by those rows; this root entry exists for
8
8
  * programmatic imports AND because the host's client-module scan identifies a
9
9
  * package's browser half through a loader row whose specifier resolves to the
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * pieces. The cordis.patch.yml rows reference subpath exports:
4
4
  * - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
5
5
  * - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
6
- * - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
6
+ * - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
7
7
  * The capability itself is composed by those rows; this root entry exists for
8
8
  * programmatic imports AND because the host's client-module scan identifies a
9
9
  * package's browser half through a loader row whose specifier resolves to the
@@ -8,9 +8,9 @@
8
8
  * and the tool's `output.schema` declared the properties. Nothing connected them, and the
9
9
  * result is this project's most repeatable bug:
10
10
  *
11
- * - `browser_a11y`'s `selector` — computed by the provider, declared by the schema,
11
+ * - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
12
12
  * printed by the renderer, and dropped by the projection. 134 tests stayed green.
13
- * - `browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
13
+ * - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
14
14
  * schema, and dropped by the projection. 172 tests stayed green. It was found a round
15
15
  * later, by reading the three places that agree and noticing the fourth did not.
16
16
  *
@@ -24,7 +24,7 @@
24
24
  * projection forgets is silently dropped. One table cannot get either wrong on its own.
25
25
  */
26
26
  /**
27
- * The `elements` item schema for browser_snapshot and browser_open.
27
+ * The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
28
28
  *
29
29
  * Written as a literal rather than generated, because `defineTool` validates the output schema
30
30
  * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
@@ -65,7 +65,7 @@ export declare const ELEMENT_ITEM_SCHEMA: {
65
65
  };
66
66
  };
67
67
  /**
68
- * The `nodes` item schema for browser_a11y.
68
+ * The `nodes` item schema for dsh_browser_a11y.
69
69
  *
70
70
  * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
71
71
  * test.
@@ -8,9 +8,9 @@
8
8
  * and the tool's `output.schema` declared the properties. Nothing connected them, and the
9
9
  * result is this project's most repeatable bug:
10
10
  *
11
- * - `browser_a11y`'s `selector` — computed by the provider, declared by the schema,
11
+ * - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
12
12
  * printed by the renderer, and dropped by the projection. 134 tests stayed green.
13
- * - `browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
13
+ * - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
14
14
  * schema, and dropped by the projection. 172 tests stayed green. It was found a round
15
15
  * later, by reading the three places that agree and noticing the fourth did not.
16
16
  *
@@ -24,7 +24,7 @@
24
24
  * projection forgets is silently dropped. One table cannot get either wrong on its own.
25
25
  */
26
26
  /**
27
- * The fields of one entry in `browser_snapshot` / `browser_open`'s `elements`.
27
+ * The fields of one entry in `dsh_browser_snapshot` / `dsh_browser_open`'s `elements`.
28
28
  *
29
29
  * `selector` is the reason this file exists; see the header. It was added to the schema and
30
30
  * the projection separately, and only one of them landed.
@@ -39,7 +39,7 @@ const ELEMENT_FIELDS = {
39
39
  frame: { schema: { type: 'boolean' } },
40
40
  };
41
41
  /**
42
- * The fields of one entry in `browser_a11y`'s `nodes`.
42
+ * The fields of one entry in `dsh_browser_a11y`'s `nodes`.
43
43
  *
44
44
  * Different shape from an element on purpose: a role and an accessible name carry meaning a
45
45
  * tag and a label string do not, and `depth` is what renders the tree.
@@ -80,7 +80,7 @@ function project(fields, source) {
80
80
  return out;
81
81
  }
82
82
  /**
83
- * The `elements` item schema for browser_snapshot and browser_open.
83
+ * The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
84
84
  *
85
85
  * Written as a literal rather than generated, because `defineTool` validates the output schema
86
86
  * against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
@@ -102,7 +102,7 @@ export const ELEMENT_ITEM_SCHEMA = {
102
102
  },
103
103
  };
104
104
  /**
105
- * The `nodes` item schema for browser_a11y.
105
+ * The `nodes` item schema for dsh_browser_a11y.
106
106
  *
107
107
  * Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
108
108
  * test.
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Model-facing browser tools over `ctx.browser`: `browser_open`,
3
- * `browser_snapshot`, `browser_execute`, `browser_content`,
4
- * `browser_screenshot`, and tab management (`browser_list_tabs`,
5
- * `browser_switch_tab`, `browser_close_tab`, `browser_reset`).
2
+ * Model-facing browser tools over `ctx.browser`: `dsh_browser_open`,
3
+ * `dsh_browser_snapshot`, `dsh_browser_execute`, `dsh_browser_content`,
4
+ * `dsh_browser_screenshot`, and tab management (`dsh_browser_list_tabs`,
5
+ * `dsh_browser_switch_tab`, `dsh_browser_close_tab`, `dsh_browser_reset`).
6
6
  *
7
7
  * The tool layer owns only the model-facing schema, argument validation, and
8
8
  * result formatting — never provider selection or page driving, which belong
9
9
  * to the seam. Session lifecycle is owned here at the plugin level: each
10
10
  * calling task (a DSH session) gets its own browser session — the first
11
- * `browser_open` (or any tool when no session exists) opens it, and later
11
+ * `dsh_browser_open` (or any tool when no session exists) opens it, and later
12
12
  * tools in the same task reuse it. Concurrent tasks therefore never fight
13
13
  * over tabs, history, or navigation state.
14
14
  * @module dsh-browser/tool-browser
@@ -38,7 +38,7 @@ export interface Config {
38
38
  * Tools that only observe, plus the ones that undo a restriction.
39
39
  *
40
40
  * Never subject to an allow-list: restricting what the agent may *do* must not also
41
- * blind it or trap it, and the tool descriptions promise as much. browser_restrict
41
+ * blind it or trap it, and the tool descriptions promise as much. dsh_browser_restrict
42
42
  * is in here so a task that restricted everything can still lift it.
43
43
  */
44
44
  export declare const READ_ONLY_TOOLS: ReadonlySet<string>;