dsh-builtin-browser 0.4.1 → 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 (57) hide show
  1. package/CHANGELOG.md +1699 -0
  2. package/README.en.md +114 -33
  3. package/README.md +108 -32
  4. package/client.js +478 -29
  5. package/cordis.patch.yml +21 -0
  6. package/desktop-bridge/install.mjs +66 -6
  7. package/desktop-bridge/plugin-browser-bridge.js +426 -167
  8. package/docs/architecture.md +14 -4
  9. package/docs/tool-reference.md +13 -13
  10. package/docs/user-guide.md +58 -3
  11. package/docs/why-browser.md +1 -1
  12. package/lib/browser/types.d.ts +34 -3
  13. package/lib/browser-electron/bridge-connection.d.ts +49 -26
  14. package/lib/browser-electron/bridge-connection.js +185 -22
  15. package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
  16. package/lib/browser-electron/desktop-bridge-host.js +151 -86
  17. package/lib/browser-electron/entry.d.ts +1 -1
  18. package/lib/browser-electron/entry.js +133 -40
  19. package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
  20. package/lib/browser-electron/ephemeral-profile.js +146 -0
  21. package/lib/browser-electron/history-store.d.ts +36 -2
  22. package/lib/browser-electron/history-store.js +76 -7
  23. package/lib/browser-electron/host-main.js +86 -28
  24. package/lib/browser-electron/provider.d.ts +106 -3
  25. package/lib/browser-electron/provider.js +407 -60
  26. package/lib/browser-electron/remote-host.d.ts +52 -3
  27. package/lib/browser-electron/remote-host.js +259 -25
  28. package/lib/browser-electron/rpc-ops.d.ts +18 -0
  29. package/lib/browser-electron/rpc-ops.js +36 -0
  30. package/lib/browser-electron/settings-route.d.ts +28 -0
  31. package/lib/browser-electron/settings-route.js +68 -0
  32. package/lib/browser-electron/settings-store.d.ts +40 -0
  33. package/lib/browser-electron/settings-store.js +160 -23
  34. package/lib/browser-electron/system-browser.d.ts +102 -12
  35. package/lib/browser-electron/system-browser.js +411 -51
  36. package/lib/index.js +0 -1
  37. package/lib/tool-browser/element-fields.d.ts +145 -0
  38. package/lib/tool-browser/element-fields.js +152 -0
  39. package/lib/tool-browser/index.js +149 -74
  40. package/package.json +10 -4
  41. package/scripts/verify-host-compat.mjs +195 -0
  42. package/src/browser/types.ts +34 -3
  43. package/src/browser-electron/bridge-connection.ts +179 -22
  44. package/src/browser-electron/desktop-bridge-host.ts +146 -84
  45. package/src/browser-electron/entry.ts +132 -39
  46. package/src/browser-electron/ephemeral-profile.ts +152 -0
  47. package/src/browser-electron/history-store.ts +81 -9
  48. package/src/browser-electron/host-main.ts +115 -36
  49. package/src/browser-electron/provider.ts +492 -69
  50. package/src/browser-electron/remote-host.ts +276 -38
  51. package/src/browser-electron/rpc-ops.ts +41 -0
  52. package/src/browser-electron/settings-route.ts +87 -0
  53. package/src/browser-electron/settings-store.ts +173 -24
  54. package/src/browser-electron/system-browser.ts +408 -39
  55. package/src/index.ts +3 -2
  56. package/src/tool-browser/element-fields.ts +167 -0
  57. package/src/tool-browser/index.ts +155 -76
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  /**
2
3
  * Transport for the desktop shell's browser bridge: one long-lived connection to a
3
4
  * loopback TCP service.
@@ -18,53 +19,157 @@
18
19
  * @module dsh-browser/browser-electron/bridge-connection
19
20
  */
20
21
  import { connect } from 'node:net';
22
+ /**
23
+ * Safety cap on one un-answered reply line. CDP answers from the sidebar (a screenshot's
24
+ * base64 is the big one) cross this socket, so the cap is generous; what it exists for is a
25
+ * peer that never sends a newline — `this.buffer += chunk` grew without bound, unlike the
26
+ * same buffer in remote-host.ts, which caps at 512 MiB.
27
+ */
28
+ const MAX_BUFFER_BYTES = 128 * 1024 * 1024;
29
+ /**
30
+ * Field this client adds to every request so the shell can echo it back and never relies on
31
+ * byte order to pair an answer with its request. Loosely named, and additive on purpose: a
32
+ * bridge that does not know it simply ignores it (the current shipped shell does), and the
33
+ * client then falls back to arrival order, which is what the protocol guaranteed before.
34
+ */
35
+ const REQUEST_ID_FIELD = 'bridgeRequestId';
21
36
  /**
22
37
  * A reusable connection to the bridge.
23
38
  *
24
- * Requests are serialised by construction: one socket, one in-flight request at a
25
- * time, answered in order. A broken socket is discarded so the next call dials
26
- * again — a dead connection must never become a dead plugin.
39
+ * Requests may be in flight CONCURRENTLY, so answers are correlated by id rather than by
40
+ * position: `call()` stamps a unique {@link REQUEST_ID_FIELD} on the request and `onData()`
41
+ * resolves the entry that carries the echoed id. A bridge that does not echo it is still
42
+ * supported — its answer is handed to the oldest outstanding request, which is the
43
+ * arrival-order rule the protocol states. Once such an answer has been seen, the connection
44
+ * stops keeping two requests in flight (a second one re-dials), so position can never be the
45
+ * only thing distinguishing two callers' answers. An answer that matches nothing is never
46
+ * handed to whichever request happens to be first: the socket is dropped instead.
47
+ *
48
+ * A broken socket is discarded so the next call dials again — a dead connection must never
49
+ * become a dead plugin.
27
50
  */
28
51
  export class BridgeConnection {
29
52
  endpoint;
53
+ endpointPath;
30
54
  socket;
31
55
  buffer = '';
56
+ /** Outstanding requests by id. */
57
+ pending = new Map();
58
+ /** Ids of the requests sent on the current socket, oldest first. */
32
59
  queue = [];
60
+ nextId = 1;
61
+ /** True once any reply arrived without an id: this bridge does not echo request ids. */
62
+ idLessBridge = false;
63
+ /** True once this connection may only have one request in flight at a time. */
64
+ serialisable = false;
33
65
  /**
34
66
  * @param endpoint - the shell's published bridge endpoint.
67
+ * @param endpointPath - where that endpoint is republished, so a moved port can be followed.
35
68
  */
36
- constructor(endpoint) {
69
+ constructor(endpoint, endpointPath) {
37
70
  this.endpoint = endpoint;
71
+ this.endpointPath = endpointPath;
72
+ }
73
+ /**
74
+ * Re-read the published endpoint, and adopt it if the shell has moved.
75
+ *
76
+ * The bridge binds an ephemeral port and picks a NEW one whenever its server is recreated. The
77
+ * endpoint file is rewritten every fifteen seconds, but a host that adopted the old one keeps
78
+ * dialling a dead port forever: measured here as `connect ECONNREFUSED 127.0.0.1:52276` while
79
+ * the live bridge sat on 52411. Discarding the socket is not enough — the comment below says a
80
+ * dead connection must never become a dead plugin, and a stale PORT makes exactly that happen.
81
+ *
82
+ * Cheap and safe: one small file read per call, no socket work.
83
+ *
84
+ * @returns true when a different endpoint was adopted.
85
+ */
86
+ refreshEndpoint() {
87
+ if (this.endpointPath === undefined)
88
+ return false;
89
+ try {
90
+ const raw = JSON.parse(readFileSync(this.endpointPath, 'utf8'));
91
+ if (typeof raw.port !== 'number' || typeof raw.token !== 'string')
92
+ return false;
93
+ if (raw.port === this.endpoint.port && raw.token === this.endpoint.token)
94
+ return false;
95
+ this.endpoint = {
96
+ port: raw.port,
97
+ token: raw.token,
98
+ pid: typeof raw.pid === 'number' ? raw.pid : this.endpoint.pid,
99
+ };
100
+ // The old socket belongs to the old port.
101
+ this.reset(new Error('dsh-builtin-browser: the bridge moved; reconnecting to the new port'));
102
+ return true;
103
+ }
104
+ catch {
105
+ return false;
106
+ }
38
107
  }
39
108
  /**
40
109
  * Send one request, reusing the socket if it is still healthy.
41
- * @param request - the request body (the token is added here).
110
+ *
111
+ * Requests may overlap: the request is written as soon as it is queued, so two concurrent
112
+ * callers have two requests in flight. Each carries a unique {@link REQUEST_ID_FIELD}, and
113
+ * `onData()` resolves the entry that carries the echoed id — which is what makes
114
+ * correlation independent of arrival order. A bridge that does not echo the field (the
115
+ * shipped shell today) still works by arrival order; see {@link onData}.
116
+ * @param request - the request body (the token and the request id are added here).
42
117
  * @param timeoutMs - how long to wait for the answer before dropping the socket.
43
118
  * @returns the bridge's answer.
44
119
  */
45
120
  async call(request, timeoutMs = 20_000) {
46
- const socket = this.ensureSocket();
121
+ // One re-dial, and only for the case this exists for: a socket that has already carried
122
+ // an id-less answer cannot have a second request in flight, because two outstanding
123
+ // requests would be matched by position — the swap this correlation prevents. It is a
124
+ // straight-line re-dial rather than a retry loop: an earlier revision re-entered call()
125
+ // from inside its own promise executor, which left an orphaned pending entry behind and,
126
+ // when the condition could not clear, spun until the process ran out of heap.
127
+ // Follow the shell if its bridge moved to a new port (see refreshEndpoint).
128
+ this.refreshEndpoint();
129
+ let socket = this.ensureSocket();
130
+ if (this.serialisable && this.queue.length > 0) {
131
+ this.reset(new Error('dsh-builtin-browser: bridge connection reopened so replies stay attributable'));
132
+ socket = this.ensureSocket();
133
+ }
134
+ const id = this.nextId++;
47
135
  return await new Promise((resolve, reject) => {
136
+ this.pending.set(id, {
137
+ resolve: answer => { clearTimeout(timer); resolve(answer); },
138
+ reject: error => { clearTimeout(timer); reject(error); },
139
+ });
140
+ this.queue.push(id);
48
141
  const timer = setTimeout(() => {
49
- const index = this.queue.findIndex(entry => entry.resolve === resolve);
50
- if (index !== -1)
51
- this.queue.splice(index, 1);
52
142
  const error = new Error(`dsh-builtin-browser: bridge timed out after ${timeoutMs}ms`);
53
- this.reset(error);
143
+ // An unanswered request desynchronises the stream for an id-less bridge, and leaves one
144
+ // outstanding for one that echoes ids — either way the next call should start on a
145
+ // fresh socket. Only when THIS request's socket is still the live one: tearing down the
146
+ // REPLACEMENT because an abandoned request timed out would break whatever is using it.
147
+ //
148
+ // This test used to read `this.pending.has(id) && this.socket === socket`, with
149
+ // `forget(id)` on the line above it — and forget() is what removes the entry that `has`
150
+ // was looking for, so the condition was false every single time and the rebuild never
151
+ // ran. A timed-out call therefore left the desynchronised socket in place, and every
152
+ // later call inherited exactly the ambiguity this reset exists to clear. Deciding
153
+ // before forgetting is the whole fix; the `else` still drops the stale entry.
154
+ if (this.socket === socket)
155
+ this.reset(error);
156
+ else
157
+ this.forget(id);
158
+ // reset() already rejected this entry through its wrapper when it ran, so this is
159
+ // either the only rejection or a no-op that guarantees the caller settles.
54
160
  reject(error);
55
161
  }, timeoutMs);
56
- this.queue.push({
57
- resolve: answer => { clearTimeout(timer); resolve(answer); },
58
- reject: error => { clearTimeout(timer); reject(error); },
59
- });
60
162
  try {
61
- socket.write(JSON.stringify({ token: this.endpoint.token, ...request }) + '\n');
163
+ // The id is added LAST so a request body can never shadow it, and the token is
164
+ // added here rather than by the caller.
165
+ socket.write(JSON.stringify({ token: this.endpoint.token, ...request, [REQUEST_ID_FIELD]: id }) + '\n');
62
166
  }
63
167
  catch (error) {
64
168
  clearTimeout(timer);
65
- this.queue.pop();
169
+ this.forget(id);
66
170
  const failure = error instanceof Error ? error : new Error(String(error));
67
- this.reset(failure);
171
+ if (this.socket === socket)
172
+ this.reset(failure);
68
173
  reject(failure);
69
174
  }
70
175
  });
@@ -80,16 +185,33 @@ export class BridgeConnection {
80
185
  const socket = connect({ host: '127.0.0.1', port: this.endpoint.port });
81
186
  socket.setEncoding('utf8');
82
187
  socket.on('data', chunk => this.onData(String(chunk)));
83
- socket.on('error', error => this.reset(error));
84
- socket.on('close', () => this.reset(new Error('dsh-builtin-browser: bridge connection closed')));
188
+ // Both handlers pass the socket they belong to, so a late event from a socket that was
189
+ // already replaced cannot tear down its successor: 'close' still fires for a socket that
190
+ // was removed mid-recycle, and acting on it there would kill a perfectly good connection.
191
+ socket.on('error', error => this.socketFailed(socket, error));
192
+ socket.on('close', () => this.socketFailed(socket, new Error('dsh-builtin-browser: bridge connection closed')));
85
193
  this.buffer = '';
86
194
  this.socket = socket;
87
195
  socket.write(JSON.stringify({ token: this.endpoint.token }) + '\n');
88
196
  return socket;
89
197
  }
90
- /** Resolve queued requests in order as answers arrive. */
198
+ /** Tear the connection down for an event raised by `source`, unless it is already gone. */
199
+ socketFailed(source, error) {
200
+ if (this.socket !== source)
201
+ return;
202
+ this.reset(error);
203
+ }
204
+ /** Resolve queued requests by id, or in order when the bridge does not echo one. */
91
205
  onData(chunk) {
92
206
  this.buffer += chunk;
207
+ // A peer that never sends a newline must not grow this buffer without bound. Failing
208
+ // the connection is the honest outcome: the caller gets an error naming the cap, while
209
+ // the next call dials a fresh socket.
210
+ if (this.buffer.length > MAX_BUFFER_BYTES) {
211
+ this.buffer = '';
212
+ this.reset(new Error(`dsh-builtin-browser: bridge reply exceeded ${String(MAX_BUFFER_BYTES)} bytes`));
213
+ return;
214
+ }
93
215
  let newline = this.buffer.indexOf('\n');
94
216
  while (newline !== -1) {
95
217
  const line = this.buffer.slice(0, newline).trim();
@@ -97,9 +219,28 @@ export class BridgeConnection {
97
219
  newline = this.buffer.indexOf('\n');
98
220
  if (line === '')
99
221
  continue;
100
- const entry = this.queue.shift();
222
+ const echoed = echoOf(line);
223
+ const id = echoed ?? this.queue[0];
224
+ if (id === undefined) {
225
+ // Nothing is waiting for this answer: the peer is answering something this socket
226
+ // never asked (or answered twice). Drop the socket rather than trust the stream.
227
+ this.reset(new Error('dsh-builtin-browser: bridge answered with no request outstanding'));
228
+ return;
229
+ }
230
+ const index = this.queue.indexOf(id);
231
+ if (index !== -1)
232
+ this.queue.splice(index, 1);
233
+ const entry = this.pending.get(id);
101
234
  if (entry === undefined)
102
235
  continue;
236
+ this.pending.delete(id);
237
+ // Id-less bridges pair by order, so a reply for the WRONG request cannot be told apart
238
+ // from the right one. Remember that this bridge does not echo ids, and serialise every
239
+ // later call so the ambiguity cannot arise again.
240
+ if (echoed === undefined) {
241
+ this.idLessBridge = true;
242
+ this.serialisable = true;
243
+ }
103
244
  try {
104
245
  const answer = JSON.parse(line);
105
246
  // A rejected token means the shell restarted with a new one, so this socket
@@ -117,6 +258,13 @@ export class BridgeConnection {
117
258
  }
118
259
  }
119
260
  }
261
+ /** Forget one request (its answer can no longer be matched). */
262
+ forget(id) {
263
+ this.pending.delete(id);
264
+ const index = this.queue.indexOf(id);
265
+ if (index !== -1)
266
+ this.queue.splice(index, 1);
267
+ }
120
268
  /** Tear the socket down, failing pending work. */
121
269
  reset(error) {
122
270
  if (this.socket !== undefined) {
@@ -126,8 +274,23 @@ export class BridgeConnection {
126
274
  socket.destroy();
127
275
  }
128
276
  this.buffer = '';
129
- const pending = this.queue.splice(0, this.queue.length);
277
+ this.queue.length = 0;
278
+ const pending = [...this.pending.values()];
279
+ this.pending.clear();
130
280
  for (const entry of pending)
131
281
  entry.reject(error);
132
282
  }
133
283
  }
284
+ /** The request id a raw answer line echoes back, when it carries one. */
285
+ function echoOf(line) {
286
+ try {
287
+ const answer = JSON.parse(line);
288
+ const value = answer[REQUEST_ID_FIELD];
289
+ return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
290
+ }
291
+ catch {
292
+ // A malformed line cannot be matched by id; the caller consumes it in order and
293
+ // reports the parse failure itself.
294
+ return undefined;
295
+ }
296
+ }
@@ -36,14 +36,51 @@ export declare function bridgeEndpointPath(): string;
36
36
  */
37
37
  export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
38
38
  private readonly connection;
39
+ /**
40
+ * One entry per view this host handed out: the conversation it serves, and the guest backing it.
41
+ *
42
+ * The conversation belongs to the ENTRY, not to this host. A single plugin process serves every
43
+ * conversation — DSH runs them all through one host instance — so an owner held on the host
44
+ * would be one conversation's id applied to all of them, and a page could still land in
45
+ * another's sidebar however carefully the bridge checked it.
46
+ */
39
47
  private readonly views;
40
48
  /**
41
- * Guests whose view is gone but whose page may still be open in the sidebar.
49
+ * This process's identity with the bridge, for tab ownership.
50
+ *
51
+ * Every DSH session is its own plugin process, and the sidebar is one surface they all share.
52
+ * A session can only see its own `views` map, so before the bridge kept a ledger it adopted
53
+ * whichever tab it found — including one another session had opened, which is how a URL from
54
+ * one session appeared in another's sidebar. The id is per process, so two sessions never
55
+ * collide and one session's tabs are never handed to another; it is deliberately not the DSH
56
+ * session id, which this layer has no access to and does not need.
57
+ *
58
+ * The whole ledger rests on that premise, so it was measured rather than assumed: with two
59
+ * sessions running, the desktop had TWO host processes (plus one main, two renderers and a
60
+ * gpu/utility pair). If sessions ever shared a process, every owner check in the bridge would
61
+ * compare equal and the bleed would return in a new shape — so a change to how DSH spawns
62
+ * sessions invalidates this file, not just this comment.
63
+ *
64
+ * It is now the CONVERSATION id rather than an invented uuid, read from the plugin's ctx by the
65
+ * same path DSH's own desktop host uses. That matters because the shell keeps one sidebar per
66
+ * conversation and writes its session id onto each sidebar container (on the React fiber,
67
+ * measured on the running app) — so the bridge can only answer "is this sidebar mine" if it has
68
+ * a comparable id. With an invented uuid it never could, which is how a command came to be typed
69
+ * into another conversation's address bar.
70
+ *
71
+ * Falls back to a random uuid when the host cannot supply one — an older DSH, or a non-desktop
72
+ * composition. That is no worse than before: the bridge then knows only "this process", and it
73
+ * refuses to operate on a sidebar it cannot prove is its own.
74
+ */
75
+ private readonly owner;
76
+ /**
77
+ * Guests whose view is gone but whose page may still be open in the sidebar, with the
78
+ * conversation each belongs to.
42
79
  *
43
- * The provider destroys its view handles before it asks for a release, so the
44
- * mapping is dropped by then — keeping the guest ids here is what lets us close
45
- * only our own tabs when several sessions are running (requirements §4: each
46
- * session gets its own page).
80
+ * The provider destroys its view handles before it asks for a release, so the mapping is dropped
81
+ * by then — keeping the guest ids here is what lets us close only our own tabs when several
82
+ * conversations are running (requirements §4: each session gets its own page). The owner rides
83
+ * along for the same reason it does in {@link views}: a release names one conversation.
47
84
  */
48
85
  private readonly orphaned;
49
86
  /**
@@ -69,49 +106,43 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
69
106
  * caller falls back to self-hosting, which always works.
70
107
  * @returns the host, or undefined when no bridge is available.
71
108
  */
72
- static discover(): Promise<DesktopBridgeViewHost | undefined>;
109
+ static discover(sessionId?: string): Promise<DesktopBridgeViewHost | undefined>;
73
110
  /** Whether this host can back views: the bridge already answered `list`. */
74
111
  available(): boolean;
75
112
  /**
76
- * The guest id backing a view, materialized on first use.
113
+ * The guest backing a view, materialized on first use.
77
114
  *
78
- * The sidebar browser is itself a multi-tab surface, so each view gets its own
79
- * tab's guest: the provider's tab bookkeeping then maps onto real tabs the human
80
- * can see and switch between. Two rules keep that honest:
81
- * - a cached guest is used as-is: probing it first cost a round-trip on every
82
- * command, so liveness is established by the command failing instead;
83
- * - a fresh view takes an unclaimed guest, growing the tab strip only when
84
- * every existing guest is already spoken for.
115
+ * The conversation that called owns exactly one sidebar page on this carrier — a human and the
116
+ * agent look at the same page — so every view resolves to that conversation's own guest, and the
117
+ * page does not multiply behind the provider's own tab bookkeeping.
118
+ *
119
+ * A cached guest is used as-is: probing it first cost a round-trip on every command, so liveness
120
+ * is established by the command failing instead.
85
121
  * @param viewId - the view whose guest is wanted.
86
- * @param url - address to use when a sidebar browser has to be opened first.
122
+ * @param url - address to use when this conversation has no page yet.
87
123
  */
88
124
  private guestFor;
89
125
  /**
90
- * Ask for at least `count` sidebar tabs and return their guest ids.
91
- * @param count - minimum number of tabs.
126
+ * @param owner - the conversation this view serves. Recorded WITH the view: one host instance
127
+ * serves every conversation, so the owner cannot live on the host.
92
128
  */
93
- private guestIds;
94
- createView(): ElectronViewHandle;
129
+ createView(owner?: string): ElectronViewHandle;
95
130
  destroyView(handle: ElectronViewHandle): void;
96
131
  /**
97
- * Nothing to show: the sidebar is already on screen, and showing it again is
98
- * the shell's business (it owns the tab strip and the visibility toggle).
99
- */
100
- showView(): void;
101
- /** Same as {@link showView}: the page is already presented by the shell. */
102
- presentView(): Promise<void>;
103
- /**
104
- * The shell owns window grouping, and one sidebar serves every view, so there
105
- * is nothing to record here. (It must not write a placeholder guest id either:
106
- * a view whose guest is not yet materialized has to stay absent from the map,
107
- * or the first real command would be sent to the placeholder.)
132
+ * Bring this view's tab to the front in the sidebar.
133
+ *
134
+ * This used to be empty, on the reasoning that the sidebar is already on screen so there is
135
+ * nothing to show. That confused "the sidebar is visible" with "this page is the one being
136
+ * looked at": opening a page left it behind whatever the human had in front, which is issue
137
+ * #23. The other two carriers both do this — the self-hosted window re-adds the view and the
138
+ * system browser calls `Page.bringToFront` — so this carrier was the odd one out.
139
+ *
140
+ * Best-effort by design: the tab strip belongs to the shell's renderer, and failing to raise
141
+ * it must not fail the navigation that has already happened.
142
+ * @param handle - the view to bring forward.
108
143
  */
109
- groupView(): void;
110
- /** Focus is the shell's to manage, and there is no separate window to raise. */
111
- focus(): Promise<void>;
144
+ showView(handle: ElectronViewHandle): void;
112
145
  onUserAction(handler: (action: BrowserUserAction) => void): void;
113
- /** The shell reports its own window lifecycle; nothing to subscribe to here. */
114
- onViewClosed(): void;
115
146
  /**
116
147
  * Release the sidebar's browser pages (requirements §3, `ui.closeWithSession`).
117
148
  *
@@ -119,9 +150,11 @@ export declare class DesktopBridgeViewHost implements ElectronBrowserViewHost {
119
150
  * belongs to the shell and would happily keep the page (and its renderer) alive.
120
151
  * Closing the tabs is what actually ends the page — and only the page: cookies
121
152
  * live in the partition, history on disk, so both survive.
153
+ * @param owner - the conversation whose pages to close. Omit to close every conversation's,
154
+ * which is only correct from a teardown that is itself global.
122
155
  * @returns a promise that settles once the shell has been asked.
123
156
  */
124
- releasePage(): Promise<void>;
157
+ releasePage(owner?: string): Promise<void>;
125
158
  /**
126
159
  * Fold the sidebar away without ending the page (`ui.autoExpandOnce` is off, or
127
160
  * the caller wants the screen back while work continues).