dsh-builtin-browser 0.2.0 → 0.3.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 (34) hide show
  1. package/README.en.md +50 -5
  2. package/README.md +62 -10
  3. package/client.js +44 -8
  4. package/desktop-bridge/install.mjs +98 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  6. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  7. package/lib/browser-electron/bridge-connection.js +133 -0
  8. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  9. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  10. package/lib/browser-electron/entry.js +81 -13
  11. package/lib/browser-electron/history-store.d.ts +5 -1
  12. package/lib/browser-electron/history-store.js +12 -1
  13. package/lib/browser-electron/provider.d.ts +42 -1
  14. package/lib/browser-electron/provider.js +96 -24
  15. package/lib/browser-electron/remote-host.d.ts +9 -1
  16. package/lib/browser-electron/remote-host.js +17 -3
  17. package/lib/browser-electron/settings-store.d.ts +5 -0
  18. package/lib/browser-electron/settings-store.js +24 -1
  19. package/lib/browser-electron/system-browser.d.ts +132 -0
  20. package/lib/browser-electron/system-browser.js +380 -0
  21. package/lib/browser-electron/virtual-cursor.d.ts +27 -2
  22. package/lib/browser-electron/virtual-cursor.js +77 -7
  23. package/lib/tool-browser/index.js +51 -15
  24. package/package.json +2 -1
  25. package/src/browser-electron/bridge-connection.ts +145 -0
  26. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  27. package/src/browser-electron/entry.ts +84 -13
  28. package/src/browser-electron/history-store.ts +11 -2
  29. package/src/browser-electron/provider.ts +124 -25
  30. package/src/browser-electron/remote-host.ts +17 -2
  31. package/src/browser-electron/settings-store.ts +30 -1
  32. package/src/browser-electron/system-browser.ts +371 -0
  33. package/src/browser-electron/virtual-cursor.ts +76 -6
  34. package/src/tool-browser/index.ts +51 -15
@@ -16,7 +16,12 @@ import z from '@deepseek-ai/schemastery'
16
16
  import type { BrowserRuntime } from '../browser/runtime.js'
17
17
  import { ElectronBrowserProvider } from './provider.js'
18
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
+ import { randomUUID } from 'node:crypto'
20
+ import { homedir } from 'node:os'
21
+ import { join } from 'node:path'
19
22
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
+ import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
+ import { detectBrowser, SystemBrowserViewHost } from './system-browser.js'
20
25
  import { SettingsStore } from './settings-store.js'
21
26
 
22
27
  export {
@@ -61,30 +66,96 @@ export const Config: z<Config> = z.object({
61
66
 
62
67
  /** Register the Electron browser provider with `ctx.browser`. */
63
68
  export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
64
- // External host (desktop shell) wins; otherwise self-host. The self-hosted
65
- // child is disposed with the fiber, mirroring the shell's lifetime.
66
- const host: ElectronBrowserViewHost = config.viewHost ?? new RemoteElectronViewHost(defaultHostMainPath())
67
69
  // One settings document per plugin instance: the settings panel writes it, the
68
70
  // provider reads it live, and both ends agree on the same file.
69
71
  const settings = new SettingsStore()
70
- // Own the disposer on THIS plugin's fiber: registerBrowserProvider's effect
71
- // is bound to the seam's own fiber (the browser row), so a reload of this
72
- // row would otherwise collide with the still-registered provider
73
- // (BROWSER_DUPLICATE_PROVIDER) or leave a stale provider behind.
74
- const unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, {
72
+ installSettingsRoute(ctx, settings)
73
+
74
+ const providerConfig = {
75
75
  httpOnly: config.httpOnly,
76
76
  downloadDir: config.downloadDir,
77
77
  snapshotMaxElements: config.snapshotMaxElements,
78
78
  contentMaxChars: config.contentMaxChars,
79
79
  settings: () => settings.get(),
80
- }))
80
+ }
81
+
82
+ // A host supplied by the composition (the desktop shell's own seam) wins and is
83
+ // synchronous, so registration stays synchronous on that path.
84
+ if (config.viewHost !== undefined) {
85
+ const external = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(config.viewHost as ElectronBrowserViewHost, providerConfig))
86
+ ctx.effect(() => () => { external() })
87
+ return
88
+ }
89
+
90
+ // Otherwise start self-hosted — a working browser from the first call, on every
91
+ // surface — and upgrade to the desktop sidebar if this machine has one. The
92
+ // upgrade is deliberately a swap of the whole provider: the two carriers own
93
+ // different lifetimes (a spawned child vs. the shell's view), and pretending
94
+ // otherwise would leave a stray window behind.
95
+ const selfHosted = new RemoteElectronViewHost(defaultHostMainPath())
96
+ let unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(selfHosted, providerConfig))
97
+ let upgraded = false
98
+
81
99
  ctx.effect(() => () => {
82
100
  unregister()
83
- if (config.viewHost === undefined && host instanceof RemoteElectronViewHost) {
84
- host.dispose()
85
- }
101
+ selfHosted.dispose()
86
102
  })
87
- installSettingsRoute(ctx, settings)
103
+
104
+ /**
105
+ * Swap the provider to another carrier, releasing the previous one.
106
+ * @param host - the carrier to adopt.
107
+ * @param note - a one-line description for the log.
108
+ */
109
+ const adopt = (host: ElectronBrowserViewHost, note: string): void => {
110
+ if (upgraded) { host.dispose?.(); return }
111
+ upgraded = true
112
+ try {
113
+ unregister()
114
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(host, providerConfig))
115
+ selfHosted.dispose()
116
+ ctx.logger?.info?.(`dsh-builtin-browser: ${note}`)
117
+ } catch (error) {
118
+ // Never leave the surface without a provider.
119
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
120
+ new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
121
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
122
+ }
123
+ }
124
+
125
+ // A browser the user explicitly asked for outranks every automatic choice: the
126
+ // whole point of the setting is that they know which browser they want. It is
127
+ // launched with a plugin-owned profile, so their own windows are untouched.
128
+ //
129
+ // Registration is INERT: constructing the host starts nothing. The browser only
130
+ // launches when a command actually needs a page, so merely running DSH never
131
+ // spawns a browser window. (An earlier version launched it here, which meant a
132
+ // browser appeared the moment the plugin loaded — reported and fixed.)
133
+ const channel = settings.get().browser.channel
134
+ if (channel !== 'bundled') {
135
+ const detected = detectBrowser(channel)
136
+ if (detected === undefined) {
137
+ ctx.logger?.warn?.(`dsh-builtin-browser: ${channel} was requested but no installation was found; using the bundled browser`)
138
+ } else {
139
+ const home = process.env.DSH_HOME ?? homedir()
140
+ // Login state lives in the browser profile, so the cookies setting decides where
141
+ // that profile goes: a stable directory keeps the user signed in across restarts
142
+ // (the point of using their own browser), while turning persistence off gets a
143
+ // throwaway directory that is removed when the browser is released.
144
+ const persist = settings.get().cookies.persist
145
+ const profileDir = persist
146
+ ? join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile`)
147
+ : join(home, 'dsh-builtin-browser-host', `${detected.kind}-profile-ephemeral-${randomUUID()}`)
148
+ adopt(new SystemBrowserViewHost(detected, profileDir, [], persist ? undefined : profileDir),
149
+ `will drive the installed ${detected.kind} (${detected.path}${persist ? '' : ', ephemeral profile'})`)
150
+ }
151
+ }
152
+
153
+ void DesktopBridgeViewHost.discover().then(sidebar => {
154
+ // No bridge (plain `dsh web`, an older desktop build, or a shell that already
155
+ // exited): keep self-hosting, which is what every surface has always done.
156
+ if (sidebar === undefined) return
157
+ adopt(sidebar, 'driving the desktop sidebar browser')
158
+ }).catch(() => { /* discovery never throws; keep self-hosting */ })
88
159
  }
89
160
 
90
161
  /** Route the settings panel reads and writes. */
@@ -89,15 +89,24 @@ export class HistoryStore {
89
89
  /**
90
90
  * Read visits, newest first. Unparseable lines are skipped; entries older
91
91
  * than the age limit are filtered out but left on disk until the next prune.
92
- * @param options - optional result cap and domain filter.
92
+ * @param options - optional result cap plus three filters: `domain` (hostname),
93
+ * `query` (substring of the URL or title) and `session` (the task that visited
94
+ * it). Filters compose, so "the pages THIS task opened on that host" is one call.
93
95
  * @returns the matching visits, newest first.
94
96
  */
95
- list(options: { readonly limit?: number; readonly domain?: string } = {}): VisitedPage[] {
97
+ list(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): VisitedPage[] {
96
98
  const all = this.readAll()
97
99
  const cutoff = Date.now() - this.maxAgeMs
98
100
  const domain = options.domain?.toLowerCase()
101
+ const query = options.query?.toLowerCase()
102
+ const session = options.session
99
103
  const matches = all.filter(page => {
100
104
  if (page.at < cutoff) return false
105
+ if (session !== undefined && page.session !== session) return false
106
+ if (query !== undefined) {
107
+ const haystack = `${page.url}\n${page.title ?? ''}`.toLowerCase()
108
+ if (!haystack.includes(query)) return false
109
+ }
101
110
  if (domain === undefined) return true
102
111
  try {
103
112
  return new URL(page.url).hostname.toLowerCase().includes(domain)
@@ -13,7 +13,7 @@ import { homedir } from 'node:os'
13
13
  import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
14
14
  import { HistoryStore, type VisitedPage } from './history-store.js'
15
15
  import type { BrowserSettings } from './settings-store.js'
16
- import { paintCursor, type CursorAction } from './virtual-cursor.js'
16
+ import { forgetCursor, paintCursor, type CursorAction } from './virtual-cursor.js'
17
17
  import type {
18
18
  BrowserA11yRequest,
19
19
  BrowserA11yResult,
@@ -185,6 +185,31 @@ export interface ElectronBrowserViewHost {
185
185
  * @param handler - called with the group (session) key; must not throw.
186
186
  */
187
187
  onViewClosed?(handler: (windowId: string) => void): void
188
+ /**
189
+ * Release the service's page(s) without ending the plugin's own lifetime.
190
+ *
191
+ * Only carriers that outlive a browser session need this. A self-hosted window
192
+ * dies with its view, but the desktop shell's sidebar keeps running no matter
193
+ * what the plugin does — so "release the browser when the session ends"
194
+ * (settings: ui.closeWithSession) has to be asked for explicitly there.
195
+ * @returns a promise that settles once the release was attempted.
196
+ */
197
+ releasePage?(): Promise<void>
198
+ /**
199
+ * Fold the carrier's presentation away while keeping the page alive.
200
+ *
201
+ * The desktop sidebar keeps running regardless of the plugin, so "do not take
202
+ * over the screen" (settings: ui.autoExpandOnce = false) can only be honoured by
203
+ * asking the shell to fold it. A self-hosted window has no such state and simply
204
+ * does not implement this.
205
+ * @returns a promise that settles once the fold was attempted.
206
+ */
207
+ collapse?(): Promise<void>
208
+ /**
209
+ * Release everything the carrier owns (a spawned browser, a socket, a child).
210
+ * Called when the plugin stops using it, never while it is still in service.
211
+ */
212
+ dispose?(): void
188
213
  }
189
214
 
190
215
  /**
@@ -670,6 +695,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
670
695
  private async settleDocument(handle: ElectronViewHandle, before: string, signal?: AbortSignal): Promise<void> {
671
696
  const started = Date.now()
672
697
  const deadline = started + SETTLE_TIMEOUT_MS
698
+ // The document is being replaced, so the synthetic pointer drawn into the old one
699
+ // is gone. Dropping the remembered position is what lets it be painted again:
700
+ // without this, a paint at coincidentally identical coordinates would be skipped
701
+ // and the pointer would never come back after a navigation.
702
+ forgetCursor(handle)
673
703
  for (;;) {
674
704
  if (signal?.aborted === true) return
675
705
  // A probe failure is expected while the navigation commits (the context is
@@ -1180,21 +1210,39 @@ export class ElectronBrowserProvider implements BrowserProvider {
1180
1210
  // iframes stay opaque — the browser forbids reading them).
1181
1211
  let content = ''
1182
1212
  if (fmt === 'txt') {
1183
- const parts = []
1184
- const textWalk = (node) => {
1185
- if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
1186
- if (node.nodeType !== Node.ELEMENT_NODE) return
1187
- const tag = node.tagName.toLowerCase()
1188
- if (tag === 'script' || tag === 'style' || tag === 'noscript') return
1189
- if (tag === 'iframe') {
1190
- try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
1191
- return
1213
+ // Prefer the browser's own rendered text.
1214
+ //
1215
+ // Walking text nodes and joining them is not equivalent: pages that wrap
1216
+ // every character in its own element — one-character spans for a
1217
+ // per-character animation, which is common — produce one text node per
1218
+ // letter, and joining those with spaces turns every word into a column of
1219
+ // letters. The rendered text reassembles them the way a reader sees them,
1220
+ // and it also honours visibility, so hidden text does not leak out.
1221
+ // (No backticks in this comment: it lives inside a template literal.)
1222
+ const rendered = typeof root.innerText === 'string' ? root.innerText : ''
1223
+ if (rendered.trim() !== '') {
1224
+ content = rendered
1225
+ } else {
1226
+ // Fallback for nodes without layout (a detached root, or a fragment):
1227
+ // collect text directly, still piercing shadow roots and same-origin
1228
+ // iframes, and join without inventing separators beyond what the markup
1229
+ // already implies.
1230
+ const parts = []
1231
+ const textWalk = (node) => {
1232
+ if (node.nodeType === Node.TEXT_NODE) { const t = (node.textContent || '').trim(); if (t) parts.push(t); return }
1233
+ if (node.nodeType !== Node.ELEMENT_NODE) return
1234
+ const tag = node.tagName.toLowerCase()
1235
+ if (tag === 'script' || tag === 'style' || tag === 'noscript') return
1236
+ if (tag === 'iframe') {
1237
+ try { const d = node.contentDocument; if (d && d.body) for (const c of d.body.childNodes) textWalk(c) } catch { /* cross-origin */ }
1238
+ return
1239
+ }
1240
+ if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
1241
+ for (const child of node.childNodes) textWalk(child)
1192
1242
  }
1193
- if (node.shadowRoot) for (const c of node.shadowRoot.childNodes) textWalk(c)
1194
- for (const child of node.childNodes) textWalk(child)
1243
+ textWalk(root)
1244
+ content = parts.join(' ')
1195
1245
  }
1196
- textWalk(root)
1197
- content = parts.join(' ')
1198
1246
  }
1199
1247
  else if (fmt === 'html') content = root.outerHTML || ''
1200
1248
  else if (fmt === 'json') content = JSON.stringify(root)
@@ -1270,6 +1318,18 @@ export class ElectronBrowserProvider implements BrowserProvider {
1270
1318
  x = Number(out.x)
1271
1319
  y = Number(out.y)
1272
1320
  } else {
1321
+ // A coordinate only exists because somebody read a picture. Under the
1322
+ // non-visual strategy the plugin must not accept one: a model that cannot
1323
+ // see cannot have produced it, so the coordinates are either copied from an
1324
+ // earlier vision pass or invented, and honouring them turns a silent
1325
+ // mis-click into a mystery. Refusing here, with a message that says what to
1326
+ // do instead, converts that into a usable instruction.
1327
+ if (this.settingsSource !== undefined && this.settingsSource().vision.strategy === 'nonVisual') {
1328
+ throw new BrowserError(
1329
+ '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',
1330
+ 'BROWSER_NON_VISUAL_COORDINATES',
1331
+ )
1332
+ }
1273
1333
  x = request.x
1274
1334
  y = request.y
1275
1335
  }
@@ -1298,7 +1358,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
1298
1358
  }
1299
1359
  // Paint before the press so the ripple is already on screen when the click
1300
1360
  // lands — the cursor exists to show the human WHERE the agent acts.
1301
- this.showCursor(handle, x, y, 'click')
1361
+ // Name the operation in the overlay: the pointer is the "agent is driving this
1362
+ // tab" signal, so the bubble is what lets a human follow along without the log.
1363
+ this.showCursor(handle, x, y, 'click', 'click', true)
1302
1364
  const release = (): void => {
1303
1365
  void handle
1304
1366
  .sendCommand('Input.dispatchMouseEvent', {
@@ -1351,7 +1413,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
1351
1413
  const locateTab = this.activeTab(s)
1352
1414
  const out = await this.runTargetScript(locateTab, script, locateMs, signal, 'BROWSER_TYPE_FAILED', 'browser: type')
1353
1415
  const cursorPoint = scriptPoint(out)
1354
- if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click')
1416
+ if (cursorPoint !== undefined) this.showCursor(locateTab.handle, cursorPoint.x, cursorPoint.y, 'click', 'type', true)
1355
1417
  }
1356
1418
  const text = 'text' in request ? request.text : ''
1357
1419
  const timeoutMs = 30_000
@@ -2274,7 +2336,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2274
2336
  * @param options - result cap and an optional hostname filter.
2275
2337
  * @returns the matching visits, or an empty list when history is disabled.
2276
2338
  */
2277
- visited(options: { readonly limit?: number; readonly domain?: string } = {}): readonly VisitedPage[] {
2339
+ visited(options: { readonly limit?: number; readonly domain?: string; readonly query?: string; readonly session?: string } = {}): readonly VisitedPage[] {
2278
2340
  return this.historyStore?.list(options) ?? []
2279
2341
  }
2280
2342
 
@@ -2287,10 +2349,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
2287
2349
  * @param x - viewport x in CSS pixels.
2288
2350
  * @param y - viewport y in CSS pixels.
2289
2351
  * @param action - click pulses a ripple; move only relocates.
2352
+ * @param label - short description of the operation, shown beside the pointer.
2353
+ * @param force - paint even when the pointer is already there (a click ripple must
2354
+ * always play; a bare move need not repeat).
2290
2355
  */
2291
- private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction): void {
2356
+ private showCursor(handle: ElectronViewHandle, x: number, y: number, action: CursorAction, label?: string, force = false): void {
2292
2357
  if (this.settingsSource !== undefined && !this.settingsSource().ui.virtualCursor) return
2293
- paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression))
2358
+ paintCursor(handle, x, y, action, (target, expression) => handleSendEvaluate(target, expression), label, force)
2294
2359
  }
2295
2360
 
2296
2361
  /**
@@ -2370,6 +2435,13 @@ export class ElectronBrowserProvider implements BrowserProvider {
2370
2435
  if (existing !== undefined) {
2371
2436
  this.sessions.delete(session)
2372
2437
  for (const tab of existing.tabs) this.host.destroyView(tab.handle)
2438
+ // Releasing the browser at session end is a setting, not fixed behaviour
2439
+ // (requirements §3). A self-hosted carrier already tore its window down with
2440
+ // the views above; the desktop's sidebar does not, so it is asked explicitly
2441
+ // — and its cookies plus the browsing history outlive the page either way.
2442
+ if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
2443
+ void this.host.releasePage?.().catch(() => undefined)
2444
+ }
2373
2445
  }
2374
2446
  return Promise.resolve()
2375
2447
  }
@@ -2481,19 +2553,46 @@ export class ElectronBrowserProvider implements BrowserProvider {
2481
2553
  }
2482
2554
  }
2483
2555
 
2484
- /** Report a failed user action to the host UI (toolbar), when supported. */
2556
+ /**
2557
+ * Report a failed user action to the host UI (toolbar), when supported.
2558
+ *
2559
+ * Two properties matter here, because this runs inside the catch of an async
2560
+ * handler (issue #16):
2561
+ * - the receiver must be preserved. Reading the method off the host and calling
2562
+ * it unbound runs the host's implementation with `this === undefined`, so its
2563
+ * very first statement (`void this.ready()`) throws, the throw escapes the
2564
+ * async catch as an unhandled rejection, and the whole DSH host exits;
2565
+ * - this method must be incapable of throwing. Reporting a failed action is
2566
+ * diagnostics: it can never be allowed to become the failure.
2567
+ */
2485
2568
  private notifyUserActionError(action: BrowserUserAction, error: unknown): void {
2486
- const notify = (this.host as { notifyUserActionError?(windowId: string, message: string): void }).notifyUserActionError
2487
2569
  const message = `action ${action.type} failed: ${String(error)}`
2488
- if (typeof notify === 'function') {
2489
- notify(action.windowId, message)
2490
- } else {
2491
- process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
2570
+ let reported = false
2571
+ try {
2572
+ const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
2573
+ const notify = host.notifyUserActionError
2574
+ if (typeof notify === 'function') {
2575
+ // Called on its owner: an unbound call here kills the host process.
2576
+ notify.call(host, action.windowId, message)
2577
+ reported = true
2578
+ }
2579
+ } catch (notifyError) {
2580
+ // Contained on purpose; the log line below still carries the original cause.
2581
+ process.stderr.write(`[dsh-browser] host notify failed: ${String(notifyError)}\n`)
2492
2582
  }
2583
+ if (!reported) process.stderr.write(`[dsh-browser] user action failed: ${message}\n`)
2493
2584
  }
2494
2585
 
2495
2586
  /** Ask the host to show the active tab's view, carrying the session label. */
2496
2587
  private showActive(s: Session): void {
2588
+ // §3: whether the agent's page takes over the screen is the user's choice, not
2589
+ // a fixed behaviour. With auto-expand off the page still exists and is still
2590
+ // driven — it is simply folded away, so the agent works without claiming the
2591
+ // sidebar. Carriers that own no presentation (self-hosted) ignore the call.
2592
+ if (this.settingsSource !== undefined && !this.settingsSource().ui.autoExpandOnce) {
2593
+ void this.host.collapse?.().catch(() => undefined)
2594
+ return
2595
+ }
2497
2596
  this.host.showView?.(this.activeTab(s).handle, s.label)
2498
2597
  }
2499
2598
 
@@ -799,12 +799,27 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
799
799
  return this.electronAvailable
800
800
  }
801
801
 
802
- /** Ensure the child is up and ready (lazy on first use; restarts after a crash). */
802
+ /**
803
+ * Ensure the child is up and ready (lazy on first use; restarts after a crash).
804
+ *
805
+ * Never throws synchronously. Callers are fire-and-forget
806
+ * (`void this.ready().then(…).catch(…)`), and a synchronous throw escapes their
807
+ * `.catch` entirely — the shape that let a failed toolbar report take the whole
808
+ * DSH host down (issue #16). A disposed host is reported as a rejected promise
809
+ * instead, which those callers already handle.
810
+ */
803
811
  private ready(): Promise<void> {
804
812
  // Fail fast on a disposed host: a recovery path can re-enter ready() after
805
813
  // dispose() cleared its state, and starting fresh here would spawn a child
806
814
  // nobody will ever kill.
807
- if (this.disposed) throw new HostGoneError('browser host disposed')
815
+ if (this.disposed) {
816
+ const gone = Promise.reject(new HostGoneError('browser host disposed'))
817
+ // Keep the rejection observable to callers while marking it handled here:
818
+ // a fire-and-forget caller that forgot to attach a handler must not turn
819
+ // this into an unhandled rejection either.
820
+ gone.catch(() => undefined)
821
+ return gone
822
+ }
808
823
  if (this.readyPromise !== undefined) return this.readyPromise
809
824
  const started = this.start()
810
825
  const wrapped = started.catch(error => {
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
14
14
  import { dirname, join } from 'node:path'
15
+ import type { BrowserChannel } from './system-browser.js'
15
16
 
16
17
  /** Vision/operation strategy the provider prefers when the model can see. */
17
18
  export type VisionStrategy = 'auto' | 'nonVisual'
@@ -41,6 +42,10 @@ export interface BrowserSettings {
41
42
  readonly vision: {
42
43
  readonly strategy: VisionStrategy
43
44
  }
45
+ /** Which browser binary carries the agent's pages. */
46
+ readonly browser: {
47
+ readonly channel: BrowserChannel
48
+ }
44
49
  /** Whether the agent may read cookies / export login state. */
45
50
  readonly credentials: {
46
51
  readonly allowRead: boolean
@@ -53,6 +58,10 @@ export const DEFAULT_SETTINGS: BrowserSettings = {
53
58
  cookies: { persist: true },
54
59
  ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
55
60
  vision: { strategy: 'auto' },
61
+ // Bundled by default: it needs nothing installed, and on the desktop the carrier
62
+ // is the sidebar anyway. Choosing a system browser is an explicit opt-in because
63
+ // it starts a real Chrome/Edge with its own profile.
64
+ browser: { channel: 'bundled' },
56
65
  credentials: { allowRead: true },
57
66
  }
58
67
 
@@ -71,6 +80,18 @@ function bool(value: unknown, fallback: boolean): boolean {
71
80
  return typeof value === 'boolean' ? value : fallback
72
81
  }
73
82
 
83
+ /**
84
+ * Coerce the browser channel, keeping the default when it is not one of the
85
+ * known choices. A hand-edited typo must not silently select a different browser.
86
+ * @param value - the raw value from the settings document.
87
+ * @returns a valid channel.
88
+ */
89
+ function resolveChannel(value: unknown): BrowserChannel {
90
+ return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
91
+ ? value
92
+ : DEFAULT_SETTINGS.browser.channel
93
+ }
94
+
74
95
  /** Coerce one positive-integer field, keeping the default when invalid. */
75
96
  function count(value: unknown, fallback: number): number {
76
97
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
@@ -96,6 +117,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
96
117
  const ui = section(source, 'ui')
97
118
  const vision = section(source, 'vision')
98
119
  const credentials = section(source, 'credentials')
120
+ const browser = section(source, 'browser')
99
121
  return {
100
122
  history: {
101
123
  enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
@@ -109,6 +131,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
109
131
  virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
110
132
  },
111
133
  vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
134
+ browser: { channel: resolveChannel(browser.channel) },
112
135
  credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
113
136
  }
114
137
  }
@@ -153,7 +176,13 @@ export class SettingsStore {
153
176
  }
154
177
  if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
155
178
  try {
156
- this.cached = text === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(text))
179
+ // A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
180
+ // `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
181
+ // Without this strip, hand-editing the settings file silently discarded every
182
+ // value in it: the document looked correct, and the plugin quietly ran on
183
+ // defaults — including switches the user had just turned off.
184
+ const body = text?.replace(/^\uFEFF/, '')
185
+ this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
157
186
  } catch {
158
187
  // Malformed JSON: behave as defaults rather than fail startup.
159
188
  this.cached = DEFAULT_SETTINGS