dsh-builtin-browser 0.4.2 → 0.4.5

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 +1699 -0
  2. package/README.en.md +63 -20
  3. package/README.md +60 -20
  4. package/client.js +418 -24
  5. package/desktop-bridge/install.mjs +66 -6
  6. package/desktop-bridge/plugin-browser-bridge.js +414 -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.js +73 -6
  16. package/lib/browser-electron/history-store.d.ts +11 -2
  17. package/lib/browser-electron/history-store.js +27 -5
  18. package/lib/browser-electron/host-main.js +11 -1
  19. package/lib/browser-electron/provider.d.ts +62 -2
  20. package/lib/browser-electron/provider.js +28 -12
  21. package/lib/browser-electron/remote-host.js +19 -6
  22. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  23. package/lib/browser-electron/rpc-ops.js +36 -0
  24. package/lib/browser-electron/settings-route.d.ts +6 -6
  25. package/lib/browser-electron/settings-route.js +48 -8
  26. package/lib/browser-electron/settings-store.js +57 -16
  27. package/lib/browser-electron/system-browser.d.ts +31 -9
  28. package/lib/browser-electron/system-browser.js +116 -27
  29. package/lib/index.js +0 -1
  30. package/lib/tool-browser/element-fields.d.ts +145 -0
  31. package/lib/tool-browser/element-fields.js +152 -0
  32. package/lib/tool-browser/index.js +26 -60
  33. package/package.json +6 -2
  34. package/src/browser/types.ts +9 -2
  35. package/src/browser-electron/bridge-connection.ts +37 -1
  36. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  37. package/src/browser-electron/entry.ts +75 -7
  38. package/src/browser-electron/history-store.ts +32 -7
  39. package/src/browser-electron/host-main.ts +10 -1
  40. package/src/browser-electron/provider.ts +91 -18
  41. package/src/browser-electron/remote-host.ts +19 -6
  42. package/src/browser-electron/rpc-ops.ts +41 -0
  43. package/src/browser-electron/settings-route.ts +54 -13
  44. package/src/browser-electron/settings-store.ts +56 -17
  45. package/src/browser-electron/system-browser.ts +121 -28
  46. package/src/index.ts +3 -2
  47. package/src/tool-browser/element-fields.ts +167 -0
  48. package/src/tool-browser/index.ts +31 -60
@@ -39,7 +39,9 @@ export function bridgeEndpointPath() {
39
39
  */
40
40
  function isGuestGone(error) {
41
41
  const message = error instanceof Error ? error.message : String(error);
42
- return /guest \d+ is not available|sidebar unavailable|did not create a browser guest/i.test(message);
42
+ // The panel path reports its own failure when the renderer never produced a page, and that is the
43
+ // same class as a vanished guest — worth one retry on a fresh panel — so it is listed here too.
44
+ return /guest \d+ is not available|sidebar unavailable|produced no page/i.test(message);
43
45
  }
44
46
  /**
45
47
  * The desktop sidebar, presented as a browser view host.
@@ -51,16 +53,53 @@ function isGuestGone(error) {
51
53
  */
52
54
  export class DesktopBridgeViewHost {
53
55
  connection;
56
+ /**
57
+ * One entry per view this host handed out: the conversation it serves, and the guest backing it.
58
+ *
59
+ * The conversation belongs to the ENTRY, not to this host. A single plugin process serves every
60
+ * conversation — DSH runs them all through one host instance — so an owner held on the host
61
+ * would be one conversation's id applied to all of them, and a page could still land in
62
+ * another's sidebar however carefully the bridge checked it.
63
+ */
54
64
  views = new Map();
55
65
  /**
56
- * Guests whose view is gone but whose page may still be open in the sidebar.
66
+ * This process's identity with the bridge, for tab ownership.
67
+ *
68
+ * Every DSH session is its own plugin process, and the sidebar is one surface they all share.
69
+ * A session can only see its own `views` map, so before the bridge kept a ledger it adopted
70
+ * whichever tab it found — including one another session had opened, which is how a URL from
71
+ * one session appeared in another's sidebar. The id is per process, so two sessions never
72
+ * collide and one session's tabs are never handed to another; it is deliberately not the DSH
73
+ * session id, which this layer has no access to and does not need.
74
+ *
75
+ * The whole ledger rests on that premise, so it was measured rather than assumed: with two
76
+ * sessions running, the desktop had TWO host processes (plus one main, two renderers and a
77
+ * gpu/utility pair). If sessions ever shared a process, every owner check in the bridge would
78
+ * compare equal and the bleed would return in a new shape — so a change to how DSH spawns
79
+ * sessions invalidates this file, not just this comment.
80
+ *
81
+ * It is now the CONVERSATION id rather than an invented uuid, read from the plugin's ctx by the
82
+ * same path DSH's own desktop host uses. That matters because the shell keeps one sidebar per
83
+ * conversation and writes its session id onto each sidebar container (on the React fiber,
84
+ * measured on the running app) — so the bridge can only answer "is this sidebar mine" if it has
85
+ * a comparable id. With an invented uuid it never could, which is how a command came to be typed
86
+ * into another conversation's address bar.
57
87
  *
58
- * The provider destroys its view handles before it asks for a release, so the
59
- * mapping is dropped by then — keeping the guest ids here is what lets us close
60
- * only our own tabs when several sessions are running (requirements §4: each
61
- * session gets its own page).
88
+ * Falls back to a random uuid when the host cannot supply one — an older DSH, or a non-desktop
89
+ * composition. That is no worse than before: the bridge then knows only "this process", and it
90
+ * refuses to operate on a sidebar it cannot prove is its own.
62
91
  */
63
- orphaned = new Set();
92
+ owner;
93
+ /**
94
+ * Guests whose view is gone but whose page may still be open in the sidebar, with the
95
+ * conversation each belongs to.
96
+ *
97
+ * The provider destroys its view handles before it asks for a release, so the mapping is dropped
98
+ * by then — keeping the guest ids here is what lets us close only our own tabs when several
99
+ * conversations are running (requirements §4: each session gets its own page). The owner rides
100
+ * along for the same reason it does in {@link views}: a release names one conversation.
101
+ */
102
+ orphaned = new Map();
64
103
  /**
65
104
  * Registered by the provider, and deliberately never invoked on this carrier.
66
105
  *
@@ -75,8 +114,9 @@ export class DesktopBridgeViewHost {
75
114
  /**
76
115
  * @param endpoint - the shell's published bridge endpoint.
77
116
  */
78
- constructor(endpoint) {
79
- this.connection = new BridgeConnection(endpoint);
117
+ constructor(endpoint, sessionId) {
118
+ this.owner = sessionId !== undefined && sessionId !== String.fromCharCode(39, 39) ? sessionId : randomUUID();
119
+ this.connection = new BridgeConnection(endpoint, bridgeEndpointPath());
80
120
  }
81
121
  /**
82
122
  * Find a usable desktop bridge, if this surface has one.
@@ -86,7 +126,7 @@ export class DesktopBridgeViewHost {
86
126
  * caller falls back to self-hosting, which always works.
87
127
  * @returns the host, or undefined when no bridge is available.
88
128
  */
89
- static async discover() {
129
+ static async discover(sessionId) {
90
130
  let endpoint;
91
131
  try {
92
132
  const raw = JSON.parse(readFileSync(bridgeEndpointPath(), 'utf8'));
@@ -100,10 +140,10 @@ export class DesktopBridgeViewHost {
100
140
  try {
101
141
  // A throwaway connection for discovery: the adopted host opens its own, and a
102
142
  // rejected endpoint must not leave a socket behind.
103
- const probe = new BridgeConnection(endpoint);
143
+ const probe = new BridgeConnection(endpoint, bridgeEndpointPath());
104
144
  try {
105
145
  const answer = await probe.call({ op: 'list' }, 5_000);
106
- return answer.ok === true ? new DesktopBridgeViewHost(endpoint) : undefined;
146
+ return answer.ok === true ? new DesktopBridgeViewHost(endpoint, sessionId) : undefined;
107
147
  }
108
148
  finally {
109
149
  probe.close();
@@ -119,62 +159,57 @@ export class DesktopBridgeViewHost {
119
159
  return true;
120
160
  }
121
161
  /**
122
- * The guest id backing a view, materialized on first use.
162
+ * The guest backing a view, materialized on first use.
123
163
  *
124
- * The sidebar browser is itself a multi-tab surface, so each view gets its own
125
- * tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
126
- * can see and switch between. Two rules keep that honest:
127
- * - a cached guest is used as-is: probing it first cost a round-trip on every
128
- * command, so liveness is established by the command failing instead;
129
- * - a fresh view takes an unclaimed guest, growing the tab strip only when
130
- * every existing guest is already spoken for.
164
+ * The conversation that called owns exactly one sidebar page on this carrier — a human and the
165
+ * agent look at the same page — so every view resolves to that conversation's own guest, and the
166
+ * page does not multiply behind the provider's own tab bookkeeping.
167
+ *
168
+ * A cached guest is used as-is: probing it first cost a round-trip on every command, so liveness
169
+ * is established by the command failing instead.
131
170
  * @param viewId - the view whose guest is wanted.
132
- * @param url - address to use when a sidebar browser has to be opened first.
171
+ * @param url - address to use when this conversation has no page yet.
133
172
  */
134
173
  async guestFor(viewId, url) {
135
- const existing = this.views.get(viewId);
174
+ const entry = this.views.get(viewId);
136
175
  // No liveness check here: verifying the cached guest cost a round-trip on every
137
176
  // command. A guest that has gone away is detected by the command itself failing,
138
177
  // and `createView`'s sendCommand then discards the cache and calls back in here.
139
- if (existing !== undefined)
140
- return existing;
141
- // The browser tab must exist before its strip can be extended: `ensureSidebar`
142
- // opens the sidebar (and, on a fresh shell, materializes the first guest).
143
- const sidebar = await this.connection.call({
178
+ if (entry?.guest !== undefined)
179
+ return entry.guest;
180
+ // The conversation THIS VIEW serves — read from the view, never from the process.
181
+ //
182
+ // The difference is the whole bug. This host is one instance shared by every conversation, so
183
+ // `this.owner` alone can only be "whichever conversation this process happens to name", and
184
+ // naming the wrong one is exactly how a page requested in one conversation appeared in
185
+ // another. The view was created for a conversation (`createView(owner)`), so that is what it
186
+ // asks for its page.
187
+ //
188
+ // Nothing here asks which conversation is on screen: `ensureSidebar` reaches this
189
+ // conversation's own panel by id through the renderer, rather than by pressing the shell's
190
+ // shortcut or clicking its card, both of which the shell delivers to whatever it displays.
191
+ const owner = entry?.owner ?? this.owner;
192
+ const answer = await this.connection.call({
144
193
  op: 'ensureSidebar',
145
- ...url !== undefined ? { url } : {},
146
- });
147
- if (sidebar.ok !== true)
148
- throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(sidebar.error)})`);
149
- const wanted = this.views.size + 1;
150
- let ids = await this.guestIds(Math.max(1, wanted));
151
- const taken = new Set(this.views.values());
152
- let free = ids.find(id => !taken.has(id));
153
- if (free === undefined) {
154
- // Every tab is already driving something: the strip has to grow.
155
- ids = await this.guestIds(ids.length + 1);
156
- free = ids.find(id => !taken.has(id));
194
+ owner,
195
+ ...url !== undefined && url !== '' ? { url } : {},
196
+ }, 30_000);
197
+ if (answer.ok !== true)
198
+ throw new Error(`dsh-builtin-browser: sidebar unavailable (${String(answer.error)})`);
199
+ const id = Number(answer.id);
200
+ if (!Number.isFinite(id) || id <= 0) {
201
+ throw new Error('dsh-builtin-browser: the sidebar reported no browser page for this conversation');
157
202
  }
158
- if (free === undefined)
159
- throw new Error('dsh-builtin-browser: the sidebar reported no free browser tab');
160
- this.views.set(viewId, free);
161
- return free;
203
+ this.views.set(viewId, { guest: id, owner });
204
+ return id;
162
205
  }
163
206
  /**
164
- * Ask for at least `count` sidebar tabs and return their guest ids.
165
- * @param count - minimum number of tabs.
207
+ * @param owner - the conversation this view serves. Recorded WITH the view: one host instance
208
+ * serves every conversation, so the owner cannot live on the host.
166
209
  */
167
- async guestIds(count) {
168
- const answer = await this.connection.call({ op: 'ensureTabs', count });
169
- if (answer.ok !== true)
170
- throw new Error(`dsh-builtin-browser: could not open a sidebar tab (${String(answer.error)})`);
171
- const ids = Array.isArray(answer.ids) ? answer.ids.map(Number).filter(Number.isFinite) : [];
172
- if (ids.length === 0)
173
- throw new Error('dsh-builtin-browser: the sidebar reported no browser tabs');
174
- return ids;
175
- }
176
- createView() {
210
+ createView(owner) {
177
211
  const viewId = randomUUID();
212
+ this.views.set(viewId, { owner: owner !== undefined && owner !== '' ? owner : this.owner });
178
213
  const navigateUrl = (method, params) => method === 'Page.navigate' ? String(params?.url ?? '') : undefined;
179
214
  const run = async (guest, method, params) => {
180
215
  const answer = await this.connection.call({ op: 'cdp', id: guest, method, params });
@@ -201,7 +236,11 @@ export class DesktopBridgeViewHost {
201
236
  // The human closed the tab: the page is gone, so take a fresh one and
202
237
  // replay the command once. This is the "closing the interface ends the
203
238
  // session" rule, now paid for only when it actually happens.
204
- this.views.delete(viewId);
239
+ // Drop the guest but keep the entry: the conversation it serves is what the retry needs,
240
+ // and removing the entry would lose it back to the process-wide fallback owner.
241
+ const lost = this.views.get(viewId);
242
+ if (lost !== undefined)
243
+ delete lost.guest;
205
244
  guest = await this.guestFor(viewId, url);
206
245
  return await run(guest, method, params);
207
246
  }
@@ -209,35 +248,39 @@ export class DesktopBridgeViewHost {
209
248
  };
210
249
  }
211
250
  destroyView(handle) {
212
- const guest = this.views.get(handle.id);
213
- if (guest !== undefined) {
214
- // Remember it: the page outlives our handle, and a later release must be able
215
- // to name exactly the tabs this plugin opened.
216
- this.orphaned.add(guest);
251
+ const entry = this.views.get(handle.id);
252
+ if (entry !== undefined) {
253
+ // Remember the page: it outlives our handle, and a later release must be able to name
254
+ // exactly the tabs this conversation's views opened. The owner comes along so a release
255
+ // naming one conversation does not sweep up another's.
256
+ if (entry.guest !== undefined)
257
+ this.orphaned.set(entry.guest, entry.owner);
217
258
  this.views.delete(handle.id);
218
259
  }
219
260
  }
220
261
  /**
221
- * Nothing to show: the sidebar is already on screen, and showing it again is
222
- * the shell's business (it owns the tab strip and the visibility toggle).
223
- */
224
- showView() { }
225
- /** Same as {@link showView}: the page is already presented by the shell. */
226
- async presentView() { }
227
- /**
228
- * The shell owns window grouping, and one sidebar serves every view, so there
229
- * is nothing to record here. (It must not write a placeholder guest id either:
230
- * a view whose guest is not yet materialized has to stay absent from the map,
231
- * or the first real command would be sent to the placeholder.)
262
+ * Bring this view's tab to the front in the sidebar.
263
+ *
264
+ * This used to be empty, on the reasoning that the sidebar is already on screen so there is
265
+ * nothing to show. That confused "the sidebar is visible" with "this page is the one being
266
+ * looked at": opening a page left it behind whatever the human had in front, which is issue
267
+ * #23. The other two carriers both do this — the self-hosted window re-adds the view and the
268
+ * system browser calls `Page.bringToFront` — so this carrier was the odd one out.
269
+ *
270
+ * Best-effort by design: the tab strip belongs to the shell's renderer, and failing to raise
271
+ * it must not fail the navigation that has already happened.
272
+ * @param handle - the view to bring forward.
232
273
  */
233
- groupView() { }
234
- /** Focus is the shell's to manage, and there is no separate window to raise. */
235
- async focus() { }
274
+ showView(handle) {
275
+ // `owner` is required: the bridge refuses to bring forward a guest held by another session.
276
+ // It comes from the view, not the process — with several conversations live, a process-wide
277
+ // owner would raise the wrong conversation's tab.
278
+ const owner = this.views.get(handle.id)?.owner ?? this.owner;
279
+ void this.connection.call({ op: 'showTab', viewId: Number(handle.id), owner }, 5_000).catch(() => undefined);
280
+ }
236
281
  onUserAction(handler) {
237
282
  this.userActionHandler = handler;
238
283
  }
239
- /** The shell reports its own window lifecycle; nothing to subscribe to here. */
240
- onViewClosed() { }
241
284
  /**
242
285
  * Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
243
286
  *
@@ -245,22 +288,42 @@ export class DesktopBridgeViewHost {
245
288
  * belongs to the shell and would happily keep the page (and its renderer) alive.
246
289
  * Closing the tabs is what actually ends the page — and only the page: cookies
247
290
  * live in the partition, history on disk, so both survive.
291
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
292
+ * which is only correct from a teardown that is itself global.
248
293
  * @returns a promise that settles once the shell has been asked.
249
294
  */
250
- async releasePage() {
251
- // Only our own tabs: the ids come from the views we handed out (and their
252
- // orphans), never from "every webview currently visible". With two sessions
253
- // live, releasing one must leave the other's page alone.
254
- const mine = [...this.views.values(), ...this.orphaned];
255
- this.views.clear();
256
- this.orphaned.clear();
295
+ async releasePage(owner) {
296
+ // Only this conversation's tabs. The host is ONE instance shared by every conversation, so a
297
+ // release that named none would close pages other conversations are still driving — "every
298
+ // webview currently visible" is precisely what must never be used here.
299
+ const mine = [];
300
+ for (const [viewId, entry] of [...this.views]) {
301
+ if (owner !== undefined && entry.owner !== owner)
302
+ continue;
303
+ if (entry.guest !== undefined)
304
+ mine.push(entry.guest);
305
+ this.views.delete(viewId);
306
+ }
307
+ // A view destroyed before the release still owns its page; its guest is collected here.
308
+ for (const [guest, holder] of [...this.orphaned]) {
309
+ if (owner !== undefined && holder !== owner)
310
+ continue;
311
+ mine.push(guest);
312
+ this.orphaned.delete(guest);
313
+ }
257
314
  // Nothing of ours is open: release nothing. Falling through to an unfiltered
258
315
  // release here would close tabs a human opened, or another session's page.
259
316
  if (mine.length === 0)
260
317
  return;
261
318
  let titles;
262
319
  try {
263
- const answer = await this.connection.call({ op: 'list' }, 5_000);
320
+ // `owner` is REQUIRED here now that the bridge filters `list` by it. Without it the bridge
321
+ // falls back to a different owner, so the guests this session holds are filtered out of the
322
+ // answer, `mine.includes(...)` matches nothing, the title list comes back empty and this
323
+ // returns without closing anything — the page stayed open and the human had to close it by
324
+ // hand. Adding the ledger to the bridge without updating this call site is exactly the kind
325
+ // of half-change that made the ownership work take four rounds.
326
+ const answer = await this.connection.call({ op: 'list', owner: owner ?? this.owner }, 5_000);
264
327
  const sidebar = Array.isArray(answer.sidebar) ? answer.sidebar : [];
265
328
  titles = sidebar
266
329
  .filter(guest => mine.includes(Number(guest.id)))
@@ -275,7 +338,9 @@ export class DesktopBridgeViewHost {
275
338
  if (titles.length === 0)
276
339
  return;
277
340
  try {
278
- await this.connection.call({ op: 'closeSidebarBrowser', titles }, 10_000);
341
+ // `owner` is required for the same reason as above: the bridge closes only the guests the
342
+ // caller holds, so an owner-less call closes nothing and the page stays open.
343
+ await this.connection.call({ op: 'closeSidebarBrowser', titles, owner: owner ?? this.owner }, 10_000);
279
344
  }
280
345
  catch {
281
346
  // Best effort: the setting expresses a preference, and a shell that cannot
@@ -35,8 +35,50 @@ export const Config = z.object({
35
35
  snapshotMaxElements: z.number(),
36
36
  contentMaxChars: z.number(),
37
37
  });
38
+ /**
39
+ * This process's session id, read the way DSH's own desktop host reads it.
40
+ *
41
+ * `dsh-desktop-host/lib/index.js` does `ctx.get('agents')` then `agents.list()`, and uses
42
+ * `agent.id` directly as a sessionId. One conversation per process, so the list holds this one.
43
+ *
44
+ * Returns undefined rather than throwing when the service is absent — an older host, or a
45
+ * non-desktop composition. The caller falls back to a random id, which is what the bridge used
46
+ * before and is safe: it only means "this process", not "this conversation".
47
+ *
48
+ * @param ctx - the plugin context.
49
+ * @returns the session id, or undefined when it cannot be read.
50
+ */
51
+ function readSessionId(ctx) {
52
+ try {
53
+ const surface = ctx;
54
+ if (typeof surface.get !== 'function')
55
+ return undefined;
56
+ const agents = surface.get('agents');
57
+ const list = typeof agents?.list === 'function' ? agents.list() : undefined;
58
+ if (!Array.isArray(list) || list.length === 0)
59
+ return undefined;
60
+ const first = list[0];
61
+ return typeof first?.id === 'string' && first.id !== '' ? first.id : undefined;
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
38
67
  /** Register the Electron browser provider with `ctx.browser`. */
39
68
  export function apply(ctx, config) {
69
+ // Which session is this process serving?
70
+ //
71
+ // The desktop shell keeps ONE sidebar per conversation — measured: two `[class*=_tabStrip]`
72
+ // containers sat in the DOM at once, each carrying its own session id on the React fiber, and
73
+ // the invisible one's webview was unloaded. So the bridge CAN tell which sidebar belongs to
74
+ // whom; what it lacked was the other half, this process's own identity. Without it, the bridge
75
+ // drove "whatever sidebar is on screen" and — measured on the running shell — typed into another
76
+ // conversation's address bar and navigated that page.
77
+ //
78
+ // The identity is available here, and DSH's own desktop host shows how
79
+ // (`dsh-desktop-host/lib/index.js`: `ctx.get('agents').list()`, with `agent.id` used directly as
80
+ // a sessionId). One conversation per process, so the list holds this one.
81
+ const sessionId = readSessionId(ctx);
40
82
  // One settings document per plugin instance: the settings panel writes it, the
41
83
  // provider reads it live, and both ends agree on the same file.
42
84
  const settings = new SettingsStore();
@@ -93,8 +135,21 @@ export function apply(ctx, config) {
93
135
  host.dispose?.();
94
136
  }
95
137
  catch { /* the fallback below matters more than this */ }
96
- unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
97
- ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
138
+ // The fallback registration gets its own guard, because THIS is the path taken when the
139
+ // context is already gone — and then `ctx.browser` throws `cannot get required service
140
+ // "browser" in inactive context`. Unguarded, that second throw escaped the async retry
141
+ // that calls `adopt`, became an unhandled rejection, and killed the host process with
142
+ // exit code 1: a failed upgrade, which should only ever cost a warning, took the whole
143
+ // session down instead. Observed 2026-10-05 (crash-…-host.log, phase: running).
144
+ try {
145
+ unregister = ctx.browser.registerBrowserProvider(new ElectronBrowserProvider(new RemoteElectronViewHost(defaultHostMainPath()), providerConfig));
146
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)})`);
147
+ }
148
+ catch (fallbackError) {
149
+ ctx.logger?.warn?.(`dsh-builtin-browser: could not adopt ${note} (${String(error)}), and the fallback `
150
+ + `registration also failed (${String(fallbackError)}) — no browser provider is `
151
+ + 'registered for this context');
152
+ }
98
153
  }
99
154
  };
100
155
  // A browser the user explicitly asked for outranks every automatic choice: the
@@ -111,8 +166,8 @@ export function apply(ctx, config) {
111
166
  // that choice real — otherwise a user who picked Chrome would silently get the sidebar,
112
167
  // and a user who picked a browser that is not installed would never see the explanation
113
168
  // written for exactly that case, because the sidebar would quietly replace it.
114
- const explicitChoice = channel !== 'bundled' && channel !== 'auto';
115
- if (channel !== 'bundled') {
169
+ const explicitChoice = channel !== 'bundled' && channel !== 'auto' && channel !== 'sidebar';
170
+ if (channel !== 'bundled' && channel !== 'sidebar') {
116
171
  const detected = detectBrowser(channel);
117
172
  if (detected === undefined) {
118
173
  // `auto` promises to take whatever is available, so falling back is what it
@@ -155,7 +210,10 @@ export function apply(ctx, config) {
155
210
  }
156
211
  // An explicit choice was already adopted above; discovering the sidebar here would
157
212
  // replace it, which is exactly what this guard prevents.
158
- if (explicitChoice)
213
+ //
214
+ // `sidebar` is explicit too, but it names the sidebar, so it is the one explicit choice that
215
+ // must FALL THROUGH to the discovery below instead of returning here.
216
+ if (explicitChoice && channel !== 'sidebar')
159
217
  return;
160
218
  // The shell may not be up yet. `dsh web` starts before — or entirely without — the desktop app
161
219
  // that hosts the bridge, and a one-shot discovery meant a bridge that appeared a second later
@@ -170,7 +228,7 @@ export function apply(ctx, config) {
170
228
  await new Promise(resolve => setTimeout(resolve, delay));
171
229
  let sidebar;
172
230
  try {
173
- sidebar = await DesktopBridgeViewHost.discover();
231
+ sidebar = await DesktopBridgeViewHost.discover(sessionId);
174
232
  }
175
233
  catch {
176
234
  sidebar = undefined; // discovery is documented not to throw; never let it stop the retries
@@ -180,6 +238,15 @@ export function apply(ctx, config) {
180
238
  adopt(sidebar, 'driving the desktop sidebar browser');
181
239
  return;
182
240
  }
241
+ // The user explicitly asked for the sidebar, so silence would leave them with a setting
242
+ // that did nothing and no way to tell why. This is reachable on `dsh web`, and on a desktop
243
+ // whose shell never published a bridge endpoint. An automatic attempt stays quiet (the
244
+ // self-hosted window is a fine outcome); an explicit request does not.
245
+ if (channel === 'sidebar') {
246
+ ctx.logger?.warn?.('dsh-builtin-browser: the sidebar browser was requested but no desktop bridge was found — '
247
+ + 'this DSH has no desktop shell, or its bridge never started. Falling back to the bundled '
248
+ + 'browser; set browser.channel to another value to silence this.');
249
+ }
183
250
  })();
184
251
  }
185
252
  /** Route the settings panel reads and writes. */
@@ -50,13 +50,22 @@ export declare class HistoryStore {
50
50
  * the common case a comparison.
51
51
  */
52
52
  private appendedSincePrune;
53
- private readonly maxEntries;
54
- private readonly maxAgeMs;
53
+ private maxEntries;
54
+ private maxAgeMs;
55
55
  /**
56
56
  * @param file - absolute path of the JSONL history file.
57
57
  * @param limits - retention overrides (entry count and age).
58
58
  */
59
59
  constructor(file?: string, limits?: HistoryLimits);
60
+ /**
61
+ * Re-read the retention limits.
62
+ *
63
+ * They are settings, and settings change while the plugin runs — a panel that let the
64
+ * operator edit them while nothing read the new values made the control a lie. Entry count
65
+ * and age are the pair the interface presents, so they are updated together.
66
+ * @param limits - the current retention overrides.
67
+ */
68
+ updateLimits(limits: HistoryLimits): void;
60
69
  /** Whether the history file exists yet (diagnostics and tests). */
61
70
  exists(): boolean;
62
71
  /**
@@ -63,9 +63,23 @@ export class HistoryStore {
63
63
  constructor(file = visitedHistoryPath(), limits = {}) {
64
64
  this.file = file;
65
65
  this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
66
- // The first rewrite is due once the file has grown PRUNE_SLACK past the cap.
66
+ this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
67
+ // The first rewrite is due once the file has grown the slack past the cap.
67
68
  this.pruneThreshold = this.maxEntries + this.slack();
69
+ }
70
+ /**
71
+ * Re-read the retention limits.
72
+ *
73
+ * They are settings, and settings change while the plugin runs — a panel that let the
74
+ * operator edit them while nothing read the new values made the control a lie. Entry count
75
+ * and age are the pair the interface presents, so they are updated together.
76
+ * @param limits - the current retention overrides.
77
+ */
78
+ updateLimits(limits) {
79
+ this.maxEntries = limits.maxEntries ?? DEFAULT_MAX_ENTRIES;
68
80
  this.maxAgeMs = (limits.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS) * 24 * 60 * 60 * 1000;
81
+ // Recomputed rather than left alone: a smaller cap means the next rewrite is sooner.
82
+ this.pruneThreshold = Math.max(this.pruneThreshold, this.maxEntries + this.slack());
69
83
  }
70
84
  /** Whether the history file exists yet (diagnostics and tests). */
71
85
  exists() {
@@ -148,15 +162,23 @@ export class HistoryStore {
148
162
  const cutoff = Date.now() - this.maxAgeMs;
149
163
  const fresh = all.filter(page => page.at >= cutoff);
150
164
  const kept = fresh.length > this.maxEntries ? fresh.slice(fresh.length - this.maxEntries) : fresh;
151
- // Only rewrite when something actually fell off, and only once the excess
152
- // is worth a full rewrite.
153
- if (kept.length === all.length)
165
+ // Every early exit has to retire the count it just spent, or the gate above stops gating.
166
+ // It counted appends so that the common "nothing to do" case is a comparison; leaving the
167
+ // counter set on the two paths that DO read the file meant it was never zero again, so
168
+ // every navigation paid the full parse this was written to avoid.
169
+ if (kept.length === all.length) {
170
+ this.appendedSincePrune = 0;
154
171
  return;
172
+ }
155
173
  // Only rewrite once the file has grown PRUNE_SLACK past what we last wrote. Asking
156
174
  // "is the file over the cap?" instead was true from the first over-cap append
157
175
  // onwards, so every single navigation after that rewrote the whole file.
158
- if (all.length < this.pruneThreshold)
176
+ if (all.length < this.pruneThreshold) {
177
+ // Deliberately NOT reset here: the count is what tracks progress toward the threshold,
178
+ // so clearing it would postpone the rewrite indefinitely. The read is the price of
179
+ // staying under the threshold, and the threshold is what bounds how often that happens.
159
180
  return;
181
+ }
160
182
  try {
161
183
  const body = kept.map(page => `${JSON.stringify(page)}\n`).join('');
162
184
  // Atomic, like the settings document. A plain writeFileSync truncates first, and this is
@@ -832,6 +832,7 @@ async function handle(op, msg) {
832
832
  catch { /* not minimized */ }
833
833
  win.window.focus();
834
834
  let base64 = '';
835
+ let renderedSize;
835
836
  try {
836
837
  let image;
837
838
  try {
@@ -856,6 +857,10 @@ async function handle(op, msg) {
856
857
  }
857
858
  if (w > 0 && h > 0 && (w !== size.width || h !== size.height))
858
859
  image = image.resize({ width: w, height: h });
860
+ // What the caller actually receives: after the downscale, not the captured size.
861
+ // Recorded here because this is the last point that knows both.
862
+ if (w > 0 && h > 0)
863
+ renderedSize = { width: w, height: h };
859
864
  const buf = wantFormat === 'jpeg' ? image.toJPEG(wantQuality) : image.toPNG();
860
865
  if (wantFormat === 'jpeg')
861
866
  mime = 'image/jpeg';
@@ -914,7 +919,12 @@ async function handle(op, msg) {
914
919
  if (base64 === '') {
915
920
  throw new Error('capture produced no image (view not painted)');
916
921
  }
917
- reply(msg.id, { ok: true, result: { base64, mime } });
922
+ // The measured size travels with the image. Without it the native path — which is the
923
+ // default self-hosted one — could never answer `browser_screenshot`'s width/height,
924
+ // because the seam's only channel was {base64, mime} and the provider had nothing to
925
+ // forward. The caller's `maxWidth`/`maxHeight` are applied here, so this is the only
926
+ // place that knows what came back.
927
+ reply(msg.id, { ok: true, result: { base64, mime, width: renderedSize?.width, height: renderedSize?.height } });
918
928
  return;
919
929
  }
920
930
  case 'download': {