dsh-builtin-browser 0.4.2 → 0.4.6

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 (49) hide show
  1. package/CHANGELOG.md +1753 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +433 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +425 -163
  7. package/docs/architecture.md +4 -4
  8. package/docs/tool-reference.md +3 -3
  9. package/docs/user-guide.md +50 -2
  10. package/lib/browser/types.d.ts +9 -2
  11. package/lib/browser-electron/bridge-connection.d.ts +18 -21
  12. package/lib/browser-electron/bridge-connection.js +42 -1
  13. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  14. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  15. package/lib/browser-electron/entry.d.ts +20 -0
  16. package/lib/browser-electron/entry.js +110 -7
  17. package/lib/browser-electron/history-store.d.ts +11 -2
  18. package/lib/browser-electron/history-store.js +27 -5
  19. package/lib/browser-electron/host-main.js +11 -1
  20. package/lib/browser-electron/provider.d.ts +62 -2
  21. package/lib/browser-electron/provider.js +28 -12
  22. package/lib/browser-electron/remote-host.js +19 -6
  23. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  24. package/lib/browser-electron/rpc-ops.js +36 -0
  25. package/lib/browser-electron/settings-route.d.ts +6 -6
  26. package/lib/browser-electron/settings-route.js +48 -8
  27. package/lib/browser-electron/settings-store.js +57 -16
  28. package/lib/browser-electron/system-browser.d.ts +31 -9
  29. package/lib/browser-electron/system-browser.js +116 -27
  30. package/lib/index.js +0 -1
  31. package/lib/tool-browser/element-fields.d.ts +145 -0
  32. package/lib/tool-browser/element-fields.js +152 -0
  33. package/lib/tool-browser/index.js +26 -60
  34. package/package.json +6 -2
  35. package/src/browser/types.ts +9 -2
  36. package/src/browser-electron/bridge-connection.ts +37 -1
  37. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  38. package/src/browser-electron/entry.ts +113 -8
  39. package/src/browser-electron/history-store.ts +32 -7
  40. package/src/browser-electron/host-main.ts +10 -1
  41. package/src/browser-electron/provider.ts +91 -18
  42. package/src/browser-electron/remote-host.ts +19 -6
  43. package/src/browser-electron/rpc-ops.ts +41 -0
  44. package/src/browser-electron/settings-route.ts +54 -13
  45. package/src/browser-electron/settings-store.ts +56 -17
  46. package/src/browser-electron/system-browser.ts +121 -28
  47. package/src/index.ts +3 -2
  48. package/src/tool-browser/element-fields.ts +167 -0
  49. package/src/tool-browser/index.ts +31 -60
@@ -17,8 +17,9 @@ import type { BrowserRuntime } from '../browser/runtime.js'
17
17
  import { ElectronBrowserProvider } from './provider.js'
18
18
  import type { ElectronBrowserViewHost } from './provider.js'
19
19
  import { randomUUID } from 'node:crypto'
20
+ import { existsSync, mkdirSync, renameSync } from 'node:fs'
20
21
  import { homedir } from 'node:os'
21
- import { join } from 'node:path'
22
+ import { dirname, join } from 'node:path'
22
23
  import { defaultHostMainPath, RemoteElectronViewHost } from './remote-host.js'
23
24
  import { DesktopBridgeViewHost } from './desktop-bridge-host.js'
24
25
  import { detectBrowser, searchSummary, SystemBrowserViewHost } from './system-browser.js'
@@ -67,8 +68,49 @@ export const Config: z<Config> = z.object({
67
68
  contentMaxChars: z.number(),
68
69
  })
69
70
 
71
+ /**
72
+ * This process's session id, read the way DSH's own desktop host reads it.
73
+ *
74
+ * `dsh-desktop-host/lib/index.js` does `ctx.get('agents')` then `agents.list()`, and uses
75
+ * `agent.id` directly as a sessionId. One conversation per process, so the list holds this one.
76
+ *
77
+ * Returns undefined rather than throwing when the service is absent — an older host, or a
78
+ * non-desktop composition. The caller falls back to a random id, which is what the bridge used
79
+ * before and is safe: it only means "this process", not "this conversation".
80
+ *
81
+ * @param ctx - the plugin context.
82
+ * @returns the session id, or undefined when it cannot be read.
83
+ */
84
+ function readSessionId(ctx: unknown): string | undefined {
85
+ try {
86
+ const surface = ctx as { get?: (name: string) => unknown }
87
+ if (typeof surface.get !== 'function') return undefined
88
+ const agents = surface.get('agents') as { list?: () => unknown } | undefined
89
+ const list = typeof agents?.list === 'function' ? agents.list() : undefined
90
+ if (!Array.isArray(list) || list.length === 0) return undefined
91
+ const first = list[0] as { id?: unknown } | undefined
92
+ return typeof first?.id === 'string' && first.id !== '' ? first.id : undefined
93
+ } catch {
94
+ return undefined
95
+ }
96
+ }
97
+
70
98
  /** Register the Electron browser provider with `ctx.browser`. */
71
99
  export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config): void {
100
+ // Which session is this process serving?
101
+ //
102
+ // The desktop shell keeps ONE sidebar per conversation — measured: two `[class*=_tabStrip]`
103
+ // containers sat in the DOM at once, each carrying its own session id on the React fiber, and
104
+ // the invisible one's webview was unloaded. So the bridge CAN tell which sidebar belongs to
105
+ // whom; what it lacked was the other half, this process's own identity. Without it, the bridge
106
+ // drove "whatever sidebar is on screen" and — measured on the running shell — typed into another
107
+ // conversation's address bar and navigated that page.
108
+ //
109
+ // The identity is available here, and DSH's own desktop host shows how
110
+ // (`dsh-desktop-host/lib/index.js`: `ctx.get('agents').list()`, with `agent.id` used directly as
111
+ // a sessionId). One conversation per process, so the list holds this one.
112
+ const sessionId = readSessionId(ctx)
113
+
72
114
  // One settings document per plugin instance: the settings panel writes it, the
73
115
  // provider reads it live, and both ends agree on the same file.
74
116
  const settings = new SettingsStore()
@@ -123,9 +165,22 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
123
165
  // process, and dropping it on the floor is how a failed upgrade becomes a leak that only
124
166
  // shows up as a stray Electron in the task manager.
125
167
  try { host.dispose?.() } catch { /* the fallback below matters more than this */ }
126
- unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
127
- new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
128
- ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
168
+ // The fallback registration gets its own guard, because THIS is the path taken when the
169
+ // context is already gone — and then `ctx.browser` throws `cannot get required service
170
+ // "browser" in inactive context`. Unguarded, that second throw escaped the async retry
171
+ // that calls `adopt`, became an unhandled rejection, and killed the host process with
172
+ // exit code 1: a failed upgrade, which should only ever cost a warning, took the whole
173
+ // session down instead. Observed 2026-10-05 (crash-…-host.log, phase: running).
174
+ try {
175
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(
176
+ new RemoteElectronViewHost(defaultHostMainPath()), providerConfig))
177
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`)
178
+ } catch (fallbackError) {
179
+ ctx.logger?.warn?.(
180
+ `dsh-builtin-browser: could not adopt ${note} (${String(error)}), and the fallback `
181
+ + `registration also failed (${String(fallbackError)}) — no browser provider is `
182
+ + 'registered for this context')
183
+ }
129
184
  }
130
185
  }
131
186
 
@@ -143,8 +198,8 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
143
198
  // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
144
199
  // and a user who picked a browser that is not installed would never see the explanation
145
200
  // written for exactly that case, because the sidebar would quietly replace it.
146
- const explicitChoice = channel !== 'bundled' && channel !== 'auto'
147
- if (channel !== 'bundled') {
201
+ const explicitChoice: boolean = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar'
202
+ if (channel !== 'bundled' && channel !== 'sidebar') {
148
203
  const detected = detectBrowser(channel)
149
204
  if (detected === undefined) {
150
205
  // `auto` promises to take whatever is available, so falling back is what it
@@ -171,6 +226,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
171
226
  // throwaway directory that is removed when the browser is released.
172
227
  const persist = settings.get().cookies.persist
173
228
  const profileRoot = join(home, BROWSER_PROFILE_DIR)
229
+ adoptLegacyProfileRoot(profileRoot)
174
230
  const profileDir = persist
175
231
  ? join(profileRoot, `${detected.kind}-profile`)
176
232
  : join(profileRoot, ephemeralProfileName(detected.kind, randomUUID()))
@@ -188,7 +244,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
188
244
 
189
245
  // An explicit choice was already adopted above; discovering the sidebar here would
190
246
  // replace it, which is exactly what this guard prevents.
191
- if (explicitChoice) return
247
+ //
248
+ // `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
249
+ // must FALL THROUGH to the discovery below instead of returning here.
250
+ if (explicitChoice && channel !== 'sidebar') return
192
251
 
193
252
  // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
194
253
  // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
@@ -202,7 +261,7 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
202
261
  if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay))
203
262
  let sidebar: Awaited<ReturnType<typeof DesktopBridgeViewHost.discover>>
204
263
  try {
205
- sidebar = await DesktopBridgeViewHost.discover()
264
+ sidebar = await DesktopBridgeViewHost.discover(sessionId)
206
265
  } catch {
207
266
  sidebar = undefined // discovery is documented not to throw; never let it stop the retries
208
267
  }
@@ -210,6 +269,17 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
210
269
  adopt(sidebar, 'driving the desktop sidebar browser')
211
270
  return
212
271
  }
272
+ // The user explicitly asked for the sidebar, so silence would leave them with a setting
273
+ // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
274
+ // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
275
+ // self-hosted window is a fine outcome); an explicit request does not.
276
+ if (channel === 'sidebar') {
277
+ ctx.logger?.warn?.(
278
+ 'dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
279
+ + 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
280
+ + 'browser; set browser.channel to another value to silence this.',
281
+ )
282
+ }
213
283
  })()
214
284
  }
215
285
 
@@ -225,6 +295,41 @@ const SETTINGS_ROUTE = '/dsh-builtin-browser/settings'
225
295
  */
226
296
  const BROWSER_PROFILE_DIR = 'dsh-builtin-browser-host'
227
297
 
298
+ /**
299
+ * Move a browser profile left behind by the old layout into the directory the plugin uses now.
300
+ *
301
+ * Before the `?.dsh` component was added here, a desktop launched from a shortcut (no `DSH_HOME`
302
+ * in the environment) put its browser profile in `<homedir>/dsh-builtin-browser-host/` while its
303
+ * settings and history lived in `<homedir>/.dsh/dsh-builtin-browser-host/`. Login state lives
304
+ * INSIDE the browser profile, so leaving the old directory behind silently signs the user out of
305
+ * everything they had signed into — reported after the fix shipped, as a one-time cost of it.
306
+ *
307
+ * That cost does not have to be paid. The move happens only when the new location is absent, so a
308
+ * profile in use is never overwritten, and every failure — a cross-device rename, a permission
309
+ * problem, a profile another process holds — falls through to an empty profile, which is exactly
310
+ * what would have happened without this function. The old directory is then left untouched rather
311
+ * than deleted, because a half-moved profile is worse than a stale one.
312
+ *
313
+ * @param profileRoot - the root the plugin resolves today (`<DSH_HOME>/dsh-builtin-browser-host`).
314
+ * @param legacyRoot - the pre-`DSH_HOME` layout's root. Injected so the move can be exercised
315
+ * without touching the real home directory.
316
+ */
317
+ export function adoptLegacyProfileRoot(
318
+ profileRoot: string,
319
+ legacyRoot: string = join(homedir(), BROWSER_PROFILE_DIR),
320
+ ): void {
321
+ try {
322
+ if (legacyRoot === profileRoot) return
323
+ if (!existsSync(legacyRoot) || existsSync(profileRoot)) return
324
+ // `renameSync` needs the destination's PARENT to exist, and on a fresh install it does not:
325
+ // the old layout never created `.dsh` at all. Creating it is harmless if it is already there.
326
+ mkdirSync(dirname(profileRoot), { recursive: true })
327
+ renameSync(legacyRoot, profileRoot)
328
+ } catch {
329
+ // Nothing here is worth failing an apply over; the fallback is the pre-existing behaviour.
330
+ }
331
+ }
332
+
228
333
  /** The host web-server surface this plugin uses, described structurally. */
229
334
  interface WebServerHost {
230
335
  readonly webServer: {
@@ -68,8 +68,8 @@ export class HistoryStore {
68
68
  * the common case a comparison.
69
69
  */
70
70
  private appendedSincePrune = 0
71
- private readonly maxEntries: number
72
- private readonly maxAgeMs: number
71
+ private maxEntries: number
72
+ private maxAgeMs: number
73
73
 
74
74
  /**
75
75
  * @param file - absolute path of the JSONL history file.
@@ -80,9 +80,24 @@ export class HistoryStore {
80
80
  limits: HistoryLimits = {},
81
81
  ) {
82
82
  this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
83
- // The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
83
+ this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
84
+ // The first rewrite is due once the file has grown the slack past the cap.
84
85
  this.pruneThreshold = this.maxEntries + this.slack()
86
+ }
87
+
88
+ /**
89
+ * Re-read the retention limits.
90
+ *
91
+ * They are settings, and settings change while the plugin runs — a panel that let the
92
+ * operator edit them while nothing read the new values made the control a lie. Entry count
93
+ * and age are the pair the interface presents, so they are updated together.
94
+ * @param limits - the current retention overrides.
95
+ */
96
+ updateLimits(limits: HistoryLimits): void {
97
+ this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES
85
98
  this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000
99
+ // Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
100
+ this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack())
86
101
  }
87
102
 
88
103
  /** Whether the history file exists yet (diagnostics and tests). */
@@ -163,13 +178,23 @@ export class HistoryStore {
163
178
  const cutoff = Date.now() - this.maxAgeMs
164
179
  const fresh = all.filter(page => page.at >= cutoff)
165
180
  const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh
166
- // Only rewrite when something actually fell off, and only once the excess
167
- // is worth a full rewrite.
168
- if (kept.length === all.length) return
181
+ // Every early exit has to retire the count it just spent, or the gate above stops gating.
182
+ // It counted appends so that the common "nothing to do" case is a comparison; leaving the
183
+ // counter set on the two paths that DO read the file meant it was never zero again, so
184
+ // every navigation paid the full parse this was written to avoid.
185
+ if (kept.length === all.length) {
186
+ this.appendedSincePrune = 0
187
+ return
188
+ }
169
189
  // Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
170
190
  // "is the file over the cap?" instead was true from the first over-cap append
171
191
  // onwards, so every single navigation after that rewrote the whole file.
172
- if (all.length < this.pruneThreshold) return
192
+ if (all.length < this.pruneThreshold) {
193
+ // Deliberately NOT reset here: the count is what tracks progress toward the threshold,
194
+ // so clearing it would postpone the rewrite indefinitely. The read is the price of
195
+ // staying under the threshold, and the threshold is what bounds how often that happens.
196
+ return
197
+ }
173
198
  try {
174
199
  const body = kept.map(page => `${JSON.stringify(page)}\n`).join('')
175
200
  // Atomic, like the settings document. A plain writeFileSync truncates first, and this is
@@ -808,6 +808,7 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
808
808
  try { win.window.restore() } catch { /* not minimized */ }
809
809
  win.window.focus()
810
810
  let base64 = ''
811
+ let renderedSize: { width: number; height: number } | undefined
811
812
  try {
812
813
  let image
813
814
  try {
@@ -832,6 +833,9 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
832
833
  if (maxW > 0 && w > maxW) { h = Math.round(h * maxW / w); w = maxW }
833
834
  if (maxH > 0 && h > maxH) { w = Math.round(w * maxH / h); h = maxH }
834
835
  if (w > 0 && h > 0 && (w !== size.width || h !== size.height)) image = image.resize({ width: w, height: h })
836
+ // What the caller actually receives: after the downscale, not the captured size.
837
+ // Recorded here because this is the last point that knows both.
838
+ if (w > 0 && h > 0) renderedSize = { width: w, height: h }
835
839
  const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG()
836
840
  if (wantFormat === 'jpeg') mime = 'image/jpeg'
837
841
  if (buf.length > 0) base64 = buf.toString('base64')
@@ -876,7 +880,12 @@ async function handle(op: string, msg: { id: number; viewId?: string; windowId?:
876
880
  if (base64 === '') {
877
881
  throw new Error('capture produced no image (view not painted)')
878
882
  }
879
- reply(msg.id, { ok: true, result: { base64, mime } })
883
+ // The measured size travels with the image. Without it the native path — which is the
884
+ // default self-hosted one — could never answer `browser_screenshot`'s width/height,
885
+ // because the seam's only channel was {base64, mime} and the provider had nothing to
886
+ // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
887
+ // place that knows what came back.
888
+ reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } })
880
889
  return
881
890
  }
882
891
  case 'download': {
@@ -118,8 +118,13 @@ export interface ElectronBrowserViewHost {
118
118
  * Create a new browser view and return a handle to its webContents-like
119
119
  * surface. The host owns windowing (adding the view to the window, sizing,
120
120
  * removal); the provider owns CDP-driven behavior.
121
+ * @param owner - the conversation this view serves. A host shared by several conversations
122
+ * needs it to keep one conversation's page out of another's; a host with one conversation per
123
+ * process may ignore it. It is per VIEW and not per host on purpose: the desktop's host is a
124
+ * single instance that every conversation runs through, so an owner held there would be one
125
+ * conversation's id applied to all of them.
121
126
  */
122
- createView(): ElectronViewHandle
127
+ createView(owner?: string): ElectronViewHandle
123
128
  /**
124
129
  * Destroy a view created by this host. Called on session close; idempotent
125
130
  * for an already-destroyed view.
@@ -159,6 +164,18 @@ export interface ElectronBrowserViewHost {
159
164
  * of failing only at first use.
160
165
  */
161
166
  available?(): boolean
167
+ /**
168
+ * Tell the human that a toolbar action the agent could not complete has failed.
169
+ *
170
+ * Optional. Declared rather than reached through a cast because this is what issue #16 was:
171
+ * the call used to be unbound, so `this` inside the host's implementation was undefined and
172
+ * its first statement threw — inside an async catch, which turned it into an unhandled
173
+ * rejection that took the whole DSH process down. A declared member is checkable; a cast is
174
+ * not.
175
+ * @param windowId - the window the action came from.
176
+ * @param message - what to show the human.
177
+ */
178
+ notifyUserActionError?(windowId: string, message: string): void
162
179
  /**
163
180
  * Optional: associate a view with a window group. Views grouped under the
164
181
  * same `windowId` share one window (one window per browser session); a host
@@ -194,7 +211,12 @@ export interface ElectronBrowserViewHost {
194
211
  * (settings: ui.closeWithSession) has to be asked for explicitly there.
195
212
  * @returns a promise that settles once the release was attempted.
196
213
  */
197
- releasePage?(): Promise<void>
214
+ /**
215
+ * Release this carrier's browser pages for one conversation.
216
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
217
+ * which is only correct from a teardown that is itself global.
218
+ */
219
+ releasePage?(owner?: string): Promise<void>
198
220
  /**
199
221
  * Fold the carrier's presentation away while keeping the page alive.
200
222
  *
@@ -257,6 +279,37 @@ export interface ElectronViewHandle {
257
279
  * then behaves exactly as before.
258
280
  */
259
281
  focus?(): Promise<void>
282
+ /**
283
+ * Save a URL straight to disk, when the backing view can do that itself.
284
+ *
285
+ * Optional. Declared here rather than reached through a structural cast at the call site:
286
+ * a cast tells the compiler nothing, so a host that implements the interface without this
287
+ * method compiles and then silently does nothing.
288
+ * @param url - the URL to fetch.
289
+ * @param savePath - where to write it.
290
+ */
291
+ download?(url: string, savePath: string): Promise<void>
292
+ /**
293
+ * Export this view's cookies. Optional; see {@link download} for why it is declared.
294
+ * @returns the cookies, scoped to the view's own page.
295
+ */
296
+ flushAuth?(): Promise<readonly ExportedCookie[]>
297
+ /**
298
+ * Import cookies into this view. Optional.
299
+ * @param cookies - the cookies to write.
300
+ * @returns how many were accepted.
301
+ */
302
+ restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number>
303
+ /**
304
+ * Capture this view natively, returning the image and its measured size.
305
+ *
306
+ * Optional. The size is part of the contract because the caller's own tool schema declares
307
+ * `width`/`height`; a host that can measure the image should say so here rather than leaving
308
+ * the provider to guess.
309
+ * @param opts - format, quality and downscale bounds.
310
+ * @returns the encoded image and the pixel size the caller will receive.
311
+ */
312
+ capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
260
313
  }
261
314
 
262
315
  /** One tab inside a session: its view plus a stable id. */
@@ -655,7 +708,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
655
708
  * in the window title so a human can tell which task's page is visible.
656
709
  */
657
710
  open(label?: string): Promise<BrowserSessionId> {
658
- const handle = this.host.createView()
711
+ // The label is the calling DSH task, and it is handed to the view as its owner: a host shared
712
+ // by several conversations (the desktop's) keeps one conversation's page out of another's on
713
+ // the strength of it, so it is not decoration.
714
+ const handle = this.host.createView(label)
659
715
  const id = `browser:${randomUUID()}`
660
716
  // Group the session's views under its own window (one window per
661
717
  // session), carrying the label for the window title. Hosts without
@@ -2595,7 +2651,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2595
2651
  // savePath admission: the shared gate — absolute, inside `downloadDir`,
2596
2652
  // and never an existing file. Screenshots use the very same one.
2597
2653
  const savePath = this.admitSavePath(request.savePath, 'download')
2598
- const downloadable = handle as { download?(url: string, savePath: string): Promise<void> }
2654
+ const downloadable = handle
2599
2655
  if (typeof downloadable.download !== 'function') {
2600
2656
  throw new BrowserError('browser: download is only available on the self-hosted browser', 'BROWSER_DOWNLOAD_UNSUPPORTED')
2601
2657
  }
@@ -2632,7 +2688,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2632
2688
  const timeoutMs = 30_000
2633
2689
  // The self-hosted carrier has a native path (the child reads its own session), which
2634
2690
  // is worth preferring: it reports exactly what the browser persisted.
2635
- const native = handle as { flushAuth?(): Promise<readonly ExportedCookie[]> }
2691
+ const native = handle
2636
2692
  if (typeof native.flushAuth === 'function') {
2637
2693
  const cookies = await withTimeout(native.flushAuth(), timeoutMs, undefined, `browser: auth export timed out after ${timeoutMs}ms`)
2638
2694
  this.record(s, 'flushAuth', {}, true, { result: `${cookies.length} cookies` })
@@ -2684,7 +2740,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2684
2740
  const s = this.session(session)
2685
2741
  const { handle } = this.activeTab(s)
2686
2742
  const timeoutMs = 30_000
2687
- const native = handle as { restoreAuth?(cookies: readonly ExportedCookie[]): Promise<number> }
2743
+ const native = handle
2688
2744
  if (typeof native.restoreAuth === 'function') {
2689
2745
  const restored = await withTimeout(native.restoreAuth(cookies), timeoutMs, undefined, `browser: auth restore timed out after ${timeoutMs}ms`)
2690
2746
  this.record(s, 'restoreAuth', { count: cookies.length }, true, { result: `${restored} cookies` })
@@ -2723,7 +2779,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2723
2779
  session: BrowserSessionId,
2724
2780
  request?: BrowserScreenshotRequest,
2725
2781
  signal?: AbortSignal,
2726
- ): Promise<{ readonly dataUrl: string; readonly path?: string }> {
2782
+ ): Promise<{ readonly dataUrl: string; readonly path?: string; readonly width?: number; readonly height?: number }> {
2727
2783
  const s = this.session(session)
2728
2784
  const { handle } = this.activeTab(s)
2729
2785
  signal?.throwIfAborted()
@@ -2734,7 +2790,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
2734
2790
  // immediately (empty) for hidden ones. JPEG and downscaling are encoded
2735
2791
  // in the child from the NativeImage, so this path is the only one that
2736
2792
  // can produce JPEG (CDP JPEG hangs on Electron 43).
2737
- const capturable = handle as { capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string }> }
2793
+ const capturable = handle
2738
2794
  if (request?.fullPage !== true && typeof capturable.capture === 'function') {
2739
2795
  // Ensure the target view is the visible one before capturing.
2740
2796
  this.showActive(s)
@@ -2752,7 +2808,17 @@ export class ElectronBrowserProvider implements BrowserProvider {
2752
2808
  if (shot.base64 === '') {
2753
2809
  throw new BrowserError('browser: capture returned an empty image (view not painted); retry shortly', 'BROWSER_SCREENSHOT_FAILED')
2754
2810
  }
2755
- return this.saveScreenshot(shot.base64, request?.savePath, shot.mime ?? 'image/png')
2811
+ return this.saveScreenshot(
2812
+ shot.base64,
2813
+ request?.savePath,
2814
+ shot.mime ?? 'image/png',
2815
+ // The child measured these after applying any downscale, and they describe the image
2816
+ // the caller receives. Without them this path — the default self-hosted one — could
2817
+ // never answer the width/height its own tool schema declares.
2818
+ shot.width !== undefined && shot.height !== undefined
2819
+ ? { width: shot.width, height: shot.height }
2820
+ : undefined,
2821
+ )
2756
2822
  }
2757
2823
  // Fallback: a handle without a native capture() (the sidebar, or an installed Chrome
2758
2824
  // or Edge) uses CDP; full-page needs `captureBeyondViewport`, which capturePage lacks.
@@ -2887,8 +2953,14 @@ export class ElectronBrowserProvider implements BrowserProvider {
2887
2953
  const store = this.historyStore
2888
2954
  if (store === undefined) return
2889
2955
  // The settings panel can switch recording off at runtime; the static config
2890
- // only supplies the startup default.
2891
- if (this.settingsSource !== undefined && !this.settingsSource().history.enabled) return
2956
+ // only supplies the startup default. The RETENTION limits are read here too: they were
2957
+ // parsed and stored but never handed to the store, so editing maxEntries or maxAgeDays in
2958
+ // the file changed nothing — the switch worked and the numbers beside it did not.
2959
+ if (this.settingsSource !== undefined) {
2960
+ const live = this.settingsSource().history
2961
+ if (!live.enabled) return
2962
+ store.updateLimits({ maxEntries: live.maxEntries, maxAgeDays: live.maxAgeDays })
2963
+ }
2892
2964
  void handleSendEvaluate(handle, 'location.href + "\\u0000" + (document.title || "")')
2893
2965
  .then(result => {
2894
2966
  if (!result.ok || typeof result.value !== 'string') return
@@ -3015,7 +3087,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
3015
3087
  // the views above; the desktop's sidebar does not, so it is asked explicitly
3016
3088
  // — and its cookies plus the browsing history outlive the page either way.
3017
3089
  if (this.settingsSource !== undefined && this.settingsSource().ui.closeWithSession) {
3018
- void this.host.releasePage?.().catch(() => undefined)
3090
+ // Named, because one host serves every conversation: an unnamed release would end the
3091
+ // pages of conversations that are still running.
3092
+ void this.host.releasePage?.(existing.label).catch(() => undefined)
3019
3093
  }
3020
3094
  }
3021
3095
  return Promise.resolve()
@@ -3039,7 +3113,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
3039
3113
 
3040
3114
  /** Append a fresh tab and make it active. */
3041
3115
  private newTab(s: Session): void {
3042
- const handle = this.host.createView()
3116
+ const handle = this.host.createView(s.label)
3043
3117
  // Same window as the session's other tabs.
3044
3118
  this.host.groupView?.(handle, s.id, s.label)
3045
3119
  s.tabs.push({ id: `tab:${randomUUID()}`, handle })
@@ -3144,11 +3218,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
3144
3218
  const message = `action ${action.type} failed: ${String(error)}`
3145
3219
  let reported = false
3146
3220
  try {
3147
- const host = this.host as { notifyUserActionError?(windowId: string, message: string): void }
3148
- const notify = host.notifyUserActionError
3149
- if (typeof notify === 'function') {
3150
- // Called on its owner: an unbound call here kills the host process.
3151
- notify.call(host, action.windowId, message)
3221
+ const notify = this.host.notifyUserActionError?.bind(this.host)
3222
+ if (notify !== undefined) {
3223
+ // Bound at the call: an unbound call here kills the host process (issue #16).
3224
+ notify(action.windowId, message)
3152
3225
  reported = true
3153
3226
  }
3154
3227
  } catch (notifyError) {
@@ -531,9 +531,11 @@ class ElectronChildClient {
531
531
  /**
532
532
  * Drop the escalation kill once there is nothing left for it to target.
533
533
  *
534
- * Without this the timer would keep the parent's event loop alive for its full grace period
535
- * after exactly the ordinary exit it was armed to catch as the bad case — and the child does
536
- * not need killing once `fail()` has run for it.
534
+ * Called on the paths where the child is known to be gone — after an observed exit, and on
535
+ * release. It is deliberately NOT called from `fail()`: a dead socket says nothing about the
536
+ * process on the other end, and the timer exists for exactly the case where the child ignores
537
+ * the socket closing. Clearing it there disarmed the only escalation there was, which is how
538
+ * a child that ignored its socket could outlive dispose() and keep holding the profile.
537
539
  */
538
540
  private clearKillGrace(): void {
539
541
  if (this.killGraceTimer === undefined) return
@@ -558,7 +560,11 @@ class ElectronChildClient {
558
560
  // kept a live timer until it fired (harmless, but it is a dangling timer in a process
559
561
  // that may be shutting down).
560
562
  this.clearHelloTimer()
561
- this.clearKillGrace()
563
+ // NOT clearKillGrace(): this path runs when the SOCKET died, which says nothing about
564
+ // whether the process did. Clearing the escalation here disarmed it for exactly the case it
565
+ // exists for — a child that ignores its socket closing — so dispose() could return with the
566
+ // child still running and still holding the profile. The timer clears itself when the child
567
+ // exits, and `kill()` replaces it on an explicit release.
562
568
  // Tag every failure as "host gone" so the view layer can tell a dead host
563
569
  // apart from a page-level error and rebuild against a fresh child.
564
570
  const wrapped = err instanceof HostGoneError ? err : new HostGoneError(err.message)
@@ -1297,10 +1303,17 @@ class DeferredRemoteView implements ElectronViewHandle {
1297
1303
  private materializeOnce(): Promise<RemoteView> {
1298
1304
  if (this.materialized === undefined) {
1299
1305
  const pending = this.materialize()
1300
- this.materialized = pending.catch(error => {
1301
- if (this.materialized === pending) this.materialized = undefined
1306
+ // Compare against the promise actually stored — the derived one — not against `pending`.
1307
+ // Storing `pending.catch(...)` and comparing `this.materialized === pending` compared two
1308
+ // different objects, so the test was never true and the reset never ran: a materialization
1309
+ // that failed with anything other than a HostGoneError (the child alive but not answering
1310
+ // a ping, say) poisoned the handle for good, and the retry the comment below promises was
1311
+ // unreachable.
1312
+ const guarded = pending.catch(error => {
1313
+ if (this.materialized === guarded) this.materialized = undefined
1302
1314
  throw error
1303
1315
  })
1316
+ this.materialized = guarded
1304
1317
  }
1305
1318
  return this.materialized
1306
1319
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The RPC operation vocabulary, in one place.
3
+ *
4
+ * The child (`host-main.ts`) switches on these names and the parent (`remote-host.ts`) sends
5
+ * them. Both spelled them out independently and nothing connected the two, so a rename on one
6
+ * side compiled fine and failed at run time as "unknown op" — and this pair has already
7
+ * produced one silent failure of that shape (`noteStartFailure`'s dedupe comparison, which was
8
+ * never true and meant a log line that never appeared).
9
+ *
10
+ * The lists are the source of truth for a test that reads both switch statements and asserts
11
+ * they agree, which is what makes a one-sided rename fail loudly instead of at run time.
12
+ */
13
+
14
+ /** Operations the parent asks the child to perform. */
15
+ export const RPC_OPS = [
16
+ 'ping',
17
+ 'createView',
18
+ 'destroyView',
19
+ 'showView',
20
+ 'groupView',
21
+ 'focus',
22
+ 'command',
23
+ 'capture',
24
+ 'download',
25
+ 'flushAuth',
26
+ 'restoreAuth',
27
+ 'navigate',
28
+ 'new-tab',
29
+ 'activate',
30
+ 'close',
31
+ 'back',
32
+ 'forward',
33
+ 'reload',
34
+ 'userActionError',
35
+ ] as const
36
+
37
+ /** Notifications the child sends the parent, which carry no reply. */
38
+ export const RPC_NOTIFICATIONS = ['userAction', 'viewClosed'] as const
39
+
40
+ /** One operation name. */
41
+ export type RpcOp = (typeof RPC_OPS)[number]