dsh-sidebar-browser-agent 0.0.0-stage → 0.2.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.
package/lib/client.js ADDED
@@ -0,0 +1,1651 @@
1
+ /**
2
+ * Browser half of `dsh-sidebar-browser-agent`.
3
+ *
4
+ * Two jobs:
5
+ * 1. Run the long-poll bridge to the Host tool half.
6
+ * 2. Drive the native right-sidebar browser. Tab management uses the public
7
+ * `ctx.sidebarRight` controller; page content, clicking, typing and
8
+ * screenshots use the **native Electron guest** the sidebar browser mounts:
9
+ * the `<webview data-sidebar-browser-frame="webview">` belonging to a tab
10
+ * body. Its Chromium guest API (`executeJavaScript`, `capturePage`,
11
+ * `sendInputEvent`, `loadURL`, `reload`) gives full access to the real
12
+ * page without any proxy or browser automation dependency.
13
+ *
14
+ * DOM mapping: the sidebar renders both a title wrapper and a body wrapper
15
+ * carrying `data-sidebar-right-tab="<tabId>"` inside one
16
+ * `[data-sidebar-right-session]` root; the body wrapper contains the webview.
17
+ *
18
+ * In a Web (non-Electron) build the sidebar browser renders a sandboxed
19
+ * cross-origin iframe instead. Tab management still works there; page actions
20
+ * fail with a clear explanation and the agent can fall back to `web_fetch`.
21
+ */
22
+ window.__ModuleLoader__.load({
23
+ id: 'dsh-sidebar-browser-agent',
24
+ factory(require) {
25
+ const React = require('react')
26
+ const PLUGIN_ROOT = 'plugin/sidebar-browser-agent'
27
+ const MAX_BACKOFF_MS = 5_000
28
+ const BASE_BACKOFF_MS = 400
29
+ const MAX_EVAL_CHARS = 100_000
30
+ /** Plugin version reported by diagnostics; keep in sync with package.json. */
31
+ const PLUGIN_VERSION = '0.2.6'
32
+ const MAX_SCREENSHOT_WIDTH = 1_400
33
+ const MAX_SCREENSHOT_BASE64 = 12_000_000
34
+ /**
35
+ * Bounds for guest operations. A hung page must never block the poll loop:
36
+ * every awaited guest call is raced against these deadlines, and a timeout
37
+ * becomes a normal command error while the bridge keeps polling.
38
+ */
39
+ const TIMEOUTS = {
40
+ guest: 25_000,
41
+ capture: 25_000,
42
+ navigation: 20_000,
43
+ }
44
+
45
+ /**
46
+ * Resolve a host route against this page's own base.
47
+ *
48
+ * A relative path keeps a reverse-proxy directory prefix, and the Electron
49
+ * desktop shell forwards every non-static `dsh-app://app/...` path to the
50
+ * Harness host, so the same expression reaches the bridge in both carriers.
51
+ * @param path - route relative to the application root.
52
+ * @returns absolute request URL.
53
+ */
54
+ function hostUrl(path) {
55
+ const raw = typeof document !== 'undefined' && typeof document.baseURI === 'string' && document.baseURI !== ''
56
+ ? document.baseURI
57
+ : 'http://dsh.internal/'
58
+ const base = new URL(raw)
59
+ base.search = ''
60
+ base.hash = ''
61
+ if (!base.pathname.endsWith('/')) base.pathname += '/'
62
+ return new URL(path.replace(/^\/+/, ''), base.href).toString()
63
+ }
64
+
65
+ /** @returns a fresh opaque page-instance id. */
66
+ function newClientId() {
67
+ try {
68
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
69
+ } catch {
70
+ /* fall through */
71
+ }
72
+ return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
73
+ }
74
+
75
+ /**
76
+ * @param error - thrown value.
77
+ * @returns a printable message.
78
+ */
79
+ function messageOf(error) {
80
+ if (error instanceof Error) return error.message
81
+ return String(error)
82
+ }
83
+
84
+ /**
85
+ * @param ms - milliseconds to wait.
86
+ * @returns a promise resolving after the delay.
87
+ */
88
+ function sleep(ms) {
89
+ return new Promise((resolve) => setTimeout(resolve, ms))
90
+ }
91
+
92
+ /**
93
+ * Race one promise against a deadline so a hung platform call cannot block
94
+ * the bridge forever.
95
+ * @param promise - the operation to bound.
96
+ * @param ms - deadline in milliseconds.
97
+ * @param label - what timed out, used in the failure text.
98
+ * @returns the operation's result.
99
+ */
100
+ function withTimeout(promise, ms, label) {
101
+ return new Promise((resolve, reject) => {
102
+ const timer = setTimeout(() => {
103
+ reject(new Error(`${label} did not finish within ${Math.round(ms / 1000)}s; the page is unresponsive, so the command was abandoned. Reload the tab or wait for the page to settle, then retry.`))
104
+ }, ms)
105
+ Promise.resolve(promise).then(
106
+ (value) => {
107
+ clearTimeout(timer)
108
+ resolve(value)
109
+ },
110
+ (error) => {
111
+ clearTimeout(timer)
112
+ reject(error)
113
+ },
114
+ )
115
+ })
116
+ }
117
+
118
+ // ---------------------------------------------------------------------
119
+ // Native carrier detection and DOM mapping
120
+ // ---------------------------------------------------------------------
121
+
122
+ /**
123
+ * Describe the carrier this page can use for page content.
124
+ * @returns `{ kind, native }` where kind is desktop-webview, web-iframe or none.
125
+ */
126
+ function detectCarrier() {
127
+ const desktop = globalThis.dshDesktop
128
+ if (desktop !== null && typeof desktop === 'object' && desktop.browser !== undefined) {
129
+ return { kind: 'electron-webview', native: true }
130
+ }
131
+ if (typeof document !== 'undefined' && document.querySelector('iframe[data-sidebar-browser-frame]') !== null) {
132
+ return { kind: 'web-iframe', native: false }
133
+ }
134
+ return { kind: 'none', native: false }
135
+ }
136
+
137
+ /**
138
+ * Locate the sidebar surface root for one session.
139
+ * @param sessionId - owning conversation.
140
+ * @returns the session root element, or undefined when no surface is mounted.
141
+ */
142
+ function sessionRootOf(sessionId) {
143
+ const roots = document.querySelectorAll('[data-sidebar-right-session]')
144
+ for (const element of roots) {
145
+ if (element.dataset.sidebarRightSession === sessionId) return element
146
+ }
147
+ return undefined
148
+ }
149
+
150
+ /**
151
+ * Locate the native guest of one sidebar browser tab.
152
+ * The title wrapper and the body wrapper share the tab marker; only the
153
+ * body wrapper contains the webview, so every candidate is searched.
154
+ * @param sessionId - owning conversation.
155
+ * @param tabId - sidebar tab id.
156
+ * @returns the live `<webview>` element.
157
+ */
158
+ function findWebview(sessionId, tabId) {
159
+ const root = sessionRootOf(sessionId)
160
+ if (root === undefined) {
161
+ throw new Error(`The right sidebar has no surface for conversation ${sessionId}; open that conversation and focus its browser tab.`)
162
+ }
163
+ const marker = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' ? CSS.escape(tabId) : tabId
164
+ const wrappers = root.querySelectorAll(`[data-sidebar-right-tab="${marker}"]`)
165
+ for (const wrapper of wrappers) {
166
+ const webview = wrapper.querySelector('webview[data-sidebar-browser-frame]')
167
+ if (webview !== null) return webview
168
+ }
169
+ throw new Error(`Browser tab ${tabId} has no native guest mounted yet. Focus that tab in the sidebar, wait for it to finish loading, or reload it with sidebar_browser action "reload".`)
170
+ }
171
+
172
+ /**
173
+ * Find the guest without throwing; used for best-effort URL/title reporting.
174
+ * @param sessionId - owning conversation.
175
+ * @param tabId - sidebar tab id.
176
+ * @returns the webview element or undefined.
177
+ */
178
+ function tryFindWebview(sessionId, tabId) {
179
+ try {
180
+ return findWebview(sessionId, tabId)
181
+ } catch {
182
+ return undefined
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Run one self-contained function inside the guest page and await its result.
188
+ * The function is serialized, so it must not close over page-module values.
189
+ * @param webview - native guest element.
190
+ * @param fn - self-contained function, optionally async.
191
+ * @param arg - JSON-serializable single argument.
192
+ * @param userGesture - whether Chromium should treat the call as user activation.
193
+ * @returns the guest result.
194
+ */
195
+ async function runInGuest(webview, fn, arg, userGesture = false) {
196
+ const source = `(${fn.toString()})(${JSON.stringify(arg === undefined ? null : arg)})`
197
+ try {
198
+ return await withTimeout(
199
+ Promise.resolve().then(() => webview.executeJavaScript(source, userGesture)),
200
+ TIMEOUTS.guest,
201
+ 'The page script',
202
+ )
203
+ } catch (error) {
204
+ if (messageOf(error).includes('did not finish within')) throw error
205
+ throw new Error(`The page context rejected the script: ${messageOf(error)}`)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Wait for the guest document to be interactive.
211
+ * @param webview - native guest element.
212
+ */
213
+ async function ensureGuestReady(webview) {
214
+ // `isLoading()` reflects every outstanding asset, so a slow image or a
215
+ // long-poll can keep it true long after the document is usable (GitHub's
216
+ // login and 2FA screens do this). Readiness therefore follows the
217
+ // document's own `readyState`, bounded so a genuinely stuck parse fails.
218
+ let state
219
+ try {
220
+ state = await runInGuest(webview, function guestDocumentReady() {
221
+ return new Promise((resolve) => {
222
+ const report = () => resolve({ readyState: document.readyState, url: location.href })
223
+ if (document.readyState !== 'loading') {
224
+ report()
225
+ return
226
+ }
227
+ const timer = setTimeout(report, 5000)
228
+ document.addEventListener('DOMContentLoaded', () => {
229
+ clearTimeout(timer)
230
+ report()
231
+ }, { once: true })
232
+ })
233
+ })
234
+ } catch (error) {
235
+ throw new Error(`The native page guest is not ready: ${messageOf(error)}`)
236
+ }
237
+ if (state === null || typeof state !== 'object' || state.readyState === 'loading') {
238
+ throw new Error('The page is still parsing its document; retry in a moment.')
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------
243
+ // Guest scripts (each self-contained: serialized with fn.toString())
244
+ // ---------------------------------------------------------------------
245
+
246
+ /** @returns page identity, viewport and scroll facts. */
247
+ function guestPageInfo() {
248
+ return {
249
+ url: location.href,
250
+ title: document.title,
251
+ readyState: document.readyState,
252
+ viewport: { width: innerWidth, height: innerHeight },
253
+ scroll: { x: Math.round(scrollX), y: Math.round(scrollY), height: document.documentElement.scrollHeight },
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Read visible text of the body or one element.
259
+ * @param arg - `{ selector?, maxChars? }`.
260
+ */
261
+ function guestReadText(arg) {
262
+ const options = arg || {}
263
+ const target = options.selector ? document.querySelector(options.selector) : document.body
264
+ if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
265
+ const text = String(target.innerText ?? '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
266
+ const max = typeof options.maxChars === 'number' ? options.maxChars : 12000
267
+ return {
268
+ ok: true,
269
+ url: location.href,
270
+ title: document.title,
271
+ text: text.slice(0, max),
272
+ totalChars: text.length,
273
+ truncated: text.length > max,
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read outer HTML of the document or one element.
279
+ * @param arg - `{ selector?, maxChars? }`.
280
+ */
281
+ function guestReadHtml(arg) {
282
+ const options = arg || {}
283
+ const target = options.selector ? document.querySelector(options.selector) : document.documentElement
284
+ if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
285
+ const html = String(target.outerHTML ?? '')
286
+ const max = typeof options.maxChars === 'number' ? options.maxChars : 30000
287
+ return {
288
+ ok: true,
289
+ url: location.href,
290
+ title: document.title,
291
+ html: html.slice(0, max),
292
+ totalChars: html.length,
293
+ truncated: html.length > max,
294
+ }
295
+ }
296
+
297
+ /**
298
+ * List visible interactive elements with stable selectors and viewport centres.
299
+ * @param arg - `{ maxElements? }`.
300
+ */
301
+ function guestInteractive(arg) {
302
+ const options = arg || {}
303
+ const max = typeof options.maxElements === 'number' ? options.maxElements : 200
304
+ const selector = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[role="menuitem"],[role="switch"],[contenteditable="true"],[onclick]'
305
+ function visible(el) {
306
+ const rect = el.getBoundingClientRect()
307
+ if (rect.width < 1 || rect.height < 1) return false
308
+ const style = getComputedStyle(el)
309
+ if (style.visibility === 'hidden' || style.display === 'none') return false
310
+ if (Number(style.opacity) === 0) return false
311
+ return true
312
+ }
313
+ function labelOf(el) {
314
+ const raw = el.getAttribute('aria-label')
315
+ || el.getAttribute('title')
316
+ || el.getAttribute('placeholder')
317
+ || (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' ? el.value : '')
318
+ || el.innerText
319
+ || ''
320
+ return String(raw).trim().replace(/\s+/g, ' ').slice(0, 120)
321
+ }
322
+ function cssPath(el) {
323
+ if (el.id !== '') {
324
+ const byId = '#' + CSS.escape(el.id)
325
+ try {
326
+ if (document.querySelectorAll(byId).length === 1) return byId
327
+ } catch (error) {
328
+ void error
329
+ }
330
+ }
331
+ const parts = []
332
+ let node = el
333
+ while (node !== null && node !== document.documentElement && parts.length < 8) {
334
+ let part = node.tagName.toLowerCase()
335
+ const parent = node.parentElement
336
+ if (parent !== null) {
337
+ const sameTag = []
338
+ for (const child of parent.children) if (child.tagName === node.tagName) sameTag.push(child)
339
+ const index = sameTag.indexOf(node)
340
+ if (sameTag.length > 1 && index >= 0) part += `:nth-of-type(${index + 1})`
341
+ }
342
+ parts.unshift(part)
343
+ if (parent === null) break
344
+ const candidate = parts.join(' > ')
345
+ try {
346
+ if (document.querySelectorAll(candidate).length === 1) return candidate
347
+ } catch (error) {
348
+ void error
349
+ }
350
+ node = parent
351
+ }
352
+ return parts.join(' > ')
353
+ }
354
+ const elements = []
355
+ const seen = new Set()
356
+ for (const el of document.querySelectorAll(selector)) {
357
+ if (seen.has(el)) continue
358
+ seen.add(el)
359
+ if (!visible(el)) continue
360
+ const rect = el.getBoundingClientRect()
361
+ const tag = el.tagName.toLowerCase()
362
+ const type = el.getAttribute('type') || ''
363
+ const label = labelOf(el)
364
+ const selectorText = cssPath(el)
365
+ const entry = {
366
+ index: elements.length,
367
+ tag,
368
+ x: Math.round(rect.left + rect.width / 2),
369
+ y: Math.round(rect.top + rect.height / 2),
370
+ }
371
+ if (type !== '') entry.type = type
372
+ if (label !== '') entry.label = label
373
+ if (selectorText !== '') entry.selector = selectorText
374
+ if (tag === 'a' && typeof el.href === 'string' && el.href !== '') entry.href = el.href.slice(0, 300)
375
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') entry.disabled = true
376
+ elements.push(entry)
377
+ if (elements.length >= max) break
378
+ }
379
+ return {
380
+ ok: true,
381
+ url: location.href,
382
+ title: document.title,
383
+ viewport: { width: innerWidth, height: innerHeight },
384
+ scroll: { x: Math.round(scrollX), y: Math.round(scrollY), height: document.documentElement.scrollHeight },
385
+ elements,
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Resolve a click target to viewport coordinates.
391
+ * @param arg - `{ selector?, contains?, x?, y? }`.
392
+ */
393
+ function guestResolveClickable(arg) {
394
+ const options = arg || {}
395
+ function visible(el) {
396
+ const rect = el.getBoundingClientRect()
397
+ if (rect.width < 1 || rect.height < 1) return false
398
+ const style = getComputedStyle(el)
399
+ return style.visibility !== 'hidden' && style.display !== 'none'
400
+ }
401
+ function labelOf(el) {
402
+ const raw = el.getAttribute('aria-label')
403
+ || el.getAttribute('title')
404
+ || el.getAttribute('placeholder')
405
+ || (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ? el.value : '')
406
+ || el.innerText
407
+ || ''
408
+ return String(raw).trim().replace(/\s+/g, ' ')
409
+ }
410
+ if (typeof options.x === 'number' && typeof options.y === 'number') {
411
+ const hit = document.elementFromPoint(options.x, options.y)
412
+ if (hit === null) return { ok: false, error: 'nothing is rendered at those coordinates' }
413
+ return {
414
+ ok: true,
415
+ x: Math.round(options.x),
416
+ y: Math.round(options.y),
417
+ tag: hit.tagName.toLowerCase(),
418
+ label: labelOf(hit).slice(0, 120),
419
+ }
420
+ }
421
+ const clickable = 'a[href],button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[role="menuitem"],[role="switch"],[contenteditable="true"],[onclick]'
422
+ let target = null
423
+ if (typeof options.selector === 'string' && options.selector !== '') {
424
+ target = document.querySelector(options.selector)
425
+ if (target === null) return { ok: false, error: `selector not found: ${options.selector}` }
426
+ } else if (typeof options.contains === 'string' && options.contains !== '') {
427
+ const needle = options.contains.toLowerCase()
428
+ for (const el of document.querySelectorAll(clickable)) {
429
+ if (!visible(el)) continue
430
+ if (labelOf(el).toLowerCase().includes(needle)) {
431
+ target = el
432
+ break
433
+ }
434
+ }
435
+ if (target === null) return { ok: false, error: `no visible clickable element contains "${options.contains}"` }
436
+ } else {
437
+ return { ok: false, error: 'click needs a selector, a contains text, or x/y coordinates' }
438
+ }
439
+ target.scrollIntoView({ block: 'center', inline: 'center' })
440
+ const rect = target.getBoundingClientRect()
441
+ if (rect.width < 1 || rect.height < 1) return { ok: false, error: 'the target has no visible box' }
442
+ return {
443
+ ok: true,
444
+ x: Math.round(rect.left + rect.width / 2),
445
+ y: Math.round(rect.top + rect.height / 2),
446
+ tag: target.tagName.toLowerCase(),
447
+ label: labelOf(target).slice(0, 120),
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Focus a form field and write text into it, dispatching real input events.
453
+ * @param arg - `{ selector?, text, clear? }`.
454
+ */
455
+ function guestFocusAndType(arg) {
456
+ const options = arg || {}
457
+ let el = null
458
+ if (typeof options.selector === 'string' && options.selector !== '') {
459
+ el = document.querySelector(options.selector)
460
+ if (el === null) return { ok: false, error: `selector not found: ${options.selector}` }
461
+ } else {
462
+ el = document.activeElement
463
+ if (el === null || el === document.body || el === document.documentElement) {
464
+ return { ok: false, error: 'no field is focused; pass a selector' }
465
+ }
466
+ }
467
+ el.scrollIntoView({ block: 'center' })
468
+ if (typeof el.focus === 'function') el.focus()
469
+ const text = String(options.text === undefined ? '' : options.text)
470
+ const clear = options.clear !== false
471
+ if (el.isContentEditable === true) {
472
+ const selection = window.getSelection()
473
+ if (selection !== null) {
474
+ const range = document.createRange()
475
+ range.selectNodeContents(el)
476
+ selection.removeAllRanges()
477
+ selection.addRange(range)
478
+ }
479
+ if (clear) document.execCommand('delete', false)
480
+ document.execCommand('insertText', false, text)
481
+ } else {
482
+ const prototype = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
483
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value')
484
+ const next = (clear ? '' : String(el.value ?? '')) + text
485
+ if (descriptor !== undefined && typeof descriptor.set === 'function') descriptor.set.call(el, next)
486
+ else el.value = next
487
+ el.dispatchEvent(new Event('input', { bubbles: true }))
488
+ el.dispatchEvent(new Event('change', { bubbles: true }))
489
+ }
490
+ return {
491
+ ok: true,
492
+ tag: el.tagName.toLowerCase(),
493
+ valueLength: String(el.value === undefined ? el.textContent ?? '' : el.value).length,
494
+ url: location.href,
495
+ title: document.title,
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Scroll the page or bring one element into view.
501
+ * @param arg - `{ dx?, dy?, selector? }`.
502
+ */
503
+ function guestScroll(arg) {
504
+ const options = arg || {}
505
+ if (typeof options.selector === 'string' && options.selector !== '') {
506
+ const el = document.querySelector(options.selector)
507
+ if (el === null) return { ok: false, error: `selector not found: ${options.selector}` }
508
+ el.scrollIntoView({ block: 'center', inline: 'nearest' })
509
+ } else {
510
+ scrollBy({ left: Number(options.dx) || 0, top: Number(options.dy) || 0, behavior: 'auto' })
511
+ }
512
+ return {
513
+ ok: true,
514
+ url: location.href,
515
+ title: document.title,
516
+ scrollX: Math.round(scrollX),
517
+ scrollY: Math.round(scrollY),
518
+ scrollHeight: document.documentElement.scrollHeight,
519
+ viewportHeight: innerHeight,
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Poll the page for a selector or visible text.
525
+ * @param arg - `{ selector?, contains?, timeoutMs? }`.
526
+ */
527
+ function guestWait(arg) {
528
+ const options = arg || {}
529
+ const timeout = typeof options.timeoutMs === 'number' ? Math.min(Math.max(options.timeoutMs, 100), 15000) : 5000
530
+ const started = performance.now()
531
+ const selector = typeof options.selector === 'string' ? options.selector : ''
532
+ const contains = typeof options.contains === 'string' ? options.contains.toLowerCase() : ''
533
+ return new Promise((resolve) => {
534
+ function check() {
535
+ const elapsed = Math.round(performance.now() - started)
536
+ if (selector !== '') {
537
+ let found = null
538
+ try {
539
+ found = document.querySelector(selector)
540
+ } catch (error) {
541
+ resolve({ ok: false, error: `invalid selector: ${String(error && error.message ? error.message : error)}` })
542
+ return
543
+ }
544
+ if (found !== null) {
545
+ found.scrollIntoView({ block: 'center' })
546
+ resolve({ ok: true, matched: true, waitedMs: elapsed, selector, url: location.href, title: document.title })
547
+ return
548
+ }
549
+ } else if (contains !== '') {
550
+ const body = document.body
551
+ const text = body === null ? '' : String(body.innerText ?? '').toLowerCase()
552
+ if (text.includes(contains)) {
553
+ resolve({ ok: true, matched: true, waitedMs: elapsed, url: location.href, title: document.title })
554
+ return
555
+ }
556
+ } else {
557
+ resolve({ ok: false, error: 'wait needs a selector or contains text' })
558
+ return
559
+ }
560
+ if (performance.now() - started >= timeout) {
561
+ resolve({ ok: true, matched: false, waitedMs: elapsed, url: location.href, title: document.title })
562
+ return
563
+ }
564
+ setTimeout(check, 120)
565
+ }
566
+ check()
567
+ })
568
+ }
569
+
570
+ // ---------------------------------------------------------------------
571
+ // Native input helpers (Electron guest)
572
+ // ---------------------------------------------------------------------
573
+
574
+ /**
575
+ * Dispatch one trusted native mouse click at guest viewport coordinates.
576
+ * @param webview - native guest element.
577
+ * @param point - `{ x, y }` CSS-pixel centre.
578
+ */
579
+ async function nativeClick(webview, point) {
580
+ const x = Math.max(0, Math.round(point.x))
581
+ const y = Math.max(0, Math.round(point.y))
582
+ if (typeof webview.focus === 'function') webview.focus()
583
+ webview.sendInputEvent({ type: 'mouseMove', x, y })
584
+ await sleep(30)
585
+ webview.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 })
586
+ await sleep(20)
587
+ webview.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 })
588
+ await sleep(60)
589
+ }
590
+
591
+ /** Keys `press` accepts, mapped to Chromium's key codes. */
592
+ const KEY_CODES = {
593
+ enter: 'Enter',
594
+ return: 'Enter',
595
+ tab: 'Tab',
596
+ escape: 'Escape',
597
+ esc: 'Escape',
598
+ backspace: 'Backspace',
599
+ delete: 'Delete',
600
+ arrowup: 'ArrowUp',
601
+ arrowdown: 'ArrowDown',
602
+ arrowleft: 'ArrowLeft',
603
+ arrowright: 'ArrowRight',
604
+ pagedown: 'PageDown',
605
+ pageup: 'PageUp',
606
+ home: 'Home',
607
+ end: 'End',
608
+ space: 'Space',
609
+ }
610
+
611
+ /**
612
+ * Dispatch one trusted native key press to the focused guest element.
613
+ * @param webview - native guest element.
614
+ * @param key - user-facing key name.
615
+ * @returns the Chromium key code used.
616
+ */
617
+ async function nativeKey(webview, key) {
618
+ const raw = String(key).trim().toLowerCase()
619
+ const code = KEY_CODES[raw] ?? (raw.length === 1 ? raw : undefined)
620
+ if (code === undefined) throw new Error(`unsupported key "${key}"; use one of: ${Object.keys(KEY_CODES).join(', ')}`)
621
+ if (typeof webview.focus === 'function') webview.focus()
622
+ webview.sendInputEvent({ type: 'keyDown', keyCode: code })
623
+ if (code === 'Enter') webview.sendInputEvent({ type: 'char', keyCode: '\r' })
624
+ webview.sendInputEvent({ type: 'keyUp', keyCode: code })
625
+ await sleep(80)
626
+ return code
627
+ }
628
+
629
+ // ---------------------------------------------------------------------
630
+ // Screenshot
631
+ // ---------------------------------------------------------------------
632
+
633
+ /**
634
+ * Capture the guest viewport as a bounded PNG.
635
+ * @param webview - native guest element.
636
+ * @returns `{ base64, width, height, originalWidth, originalHeight }`.
637
+ */
638
+ async function captureScreenshot(webview) {
639
+ if (typeof webview.capturePage !== 'function') throw new Error('the native guest exposes no capturePage API')
640
+ let image
641
+ try {
642
+ image = await withTimeout(
643
+ Promise.resolve().then(() => webview.capturePage()),
644
+ TIMEOUTS.capture,
645
+ 'The screenshot',
646
+ )
647
+ } catch (error) {
648
+ if (messageOf(error).includes('did not finish within')) throw error
649
+ throw new Error(`The native page could not be captured (${messageOf(error)}). Chromium cannot capture a guest whose conversation is hidden; switch back to the calling conversation or focus its browser tab, then retry the screenshot.`)
650
+ }
651
+ const size = image.getSize()
652
+ let output = image
653
+ let width = size.width
654
+ const widths = [MAX_SCREENSHOT_WIDTH, 1200, 1000, 800]
655
+ for (const candidate of widths) {
656
+ if (width <= candidate) break
657
+ output = image.resize({ width: candidate, quality: 'good' })
658
+ width = output.getSize().width
659
+ const probe = output.toDataURL()
660
+ if (probe.length - probe.indexOf(',') - 1 <= MAX_SCREENSHOT_BASE64) break
661
+ }
662
+ const dataUrl = output.toDataURL()
663
+ const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1)
664
+ if (base64.length === 0) throw new Error('the native guest returned an empty screenshot')
665
+ if (base64.length > MAX_SCREENSHOT_BASE64) throw new Error('the screenshot is too large to transfer; scroll or zoom out and retry')
666
+ const finalSize = output.getSize()
667
+ return {
668
+ base64,
669
+ width: finalSize.width,
670
+ height: finalSize.height,
671
+ originalWidth: size.width,
672
+ originalHeight: size.height,
673
+ }
674
+ }
675
+
676
+ // ---------------------------------------------------------------------
677
+ // Sidebar state helpers
678
+ // ---------------------------------------------------------------------
679
+
680
+ /**
681
+ * @param ctx - client plugin context.
682
+ * @returns the session the right sidebar currently shows, if any.
683
+ */
684
+ function mountedSession(ctx) {
685
+ try {
686
+ const value = ctx.sidebarRight?.mounted?.getSnapshot?.()
687
+ return typeof value === 'string' && value !== '' ? value : undefined
688
+ } catch {
689
+ return undefined
690
+ }
691
+ }
692
+
693
+ /**
694
+ * @param ctx - client plugin context.
695
+ * @returns every known open tab record (all sessions).
696
+ */
697
+ function allOpenTabs(ctx) {
698
+ try {
699
+ const value = ctx.sidebarRight?.openTabs?.getSnapshot?.()
700
+ return Array.isArray(value) ? value : []
701
+ } catch {
702
+ return []
703
+ }
704
+ }
705
+
706
+ /**
707
+ * @param ctx - client plugin context.
708
+ * @param sessionId - session to filter by.
709
+ * @returns that session's native browser tabs.
710
+ */
711
+ function browserTabs(ctx, sessionId) {
712
+ return allOpenTabs(ctx).filter((tab) => tab !== null && typeof tab === 'object' && tab.kind === 'browser' && tab.sessionId === sessionId)
713
+ }
714
+
715
+ /**
716
+ * Require the calling session to be the one on screen.
717
+ * @param ctx - client plugin context.
718
+ * @param sessionId - calling conversation.
719
+ * @returns the on-screen session.
720
+ */
721
+ function requireMounted(ctx, sessionId) {
722
+ const mounted = mountedSession(ctx)
723
+ if (mounted === undefined) {
724
+ throw new Error('The right sidebar has no conversation on screen; open the calling conversation and retry.')
725
+ }
726
+ if (mounted !== sessionId) {
727
+ throw new Error(`The right sidebar is showing conversation ${mounted}, not the calling conversation ${sessionId}; switch to the calling conversation and retry.`)
728
+ }
729
+ return mounted
730
+ }
731
+
732
+ /**
733
+ * Require the calling conversation's sidebar surface to exist in this page.
734
+ * The surface stays mounted (hidden) while another conversation is on
735
+ * screen, so page work continues in the background.
736
+ * @param sessionId - calling conversation.
737
+ */
738
+ function assertPageSurface(sessionId) {
739
+ if (sessionRootOf(sessionId) === undefined) {
740
+ throw new Error(`The calling conversation ${sessionId} has no right-sidebar surface in this page yet; switch to that conversation once so its sidebar initializes, then retry.`)
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Resolve a tab the tool named and prove it belongs to the calling session.
746
+ * @param ctx - client plugin context.
747
+ * @param sessionId - calling conversation.
748
+ * @param tabId - model-supplied tab id, or empty for the active tab.
749
+ * @returns the matching open-tab record.
750
+ */
751
+ function resolveTab(ctx, sessionId, tabId) {
752
+ const tabs = browserTabs(ctx, sessionId)
753
+ if (tabs.length === 0) {
754
+ throw new Error('No native browser tab is open in this conversation; open one with sidebar_browser action "open" first.')
755
+ }
756
+ if (typeof tabId === 'string' && tabId !== '') {
757
+ const tab = tabs.find((candidate) => candidate.tabId === tabId)
758
+ if (tab === undefined) {
759
+ throw new Error(`No native browser tab "${tabId}" is open for this conversation; call sidebar_browser action "list" to see the open tabs.`)
760
+ }
761
+ return tab
762
+ }
763
+ if (mountedSession(ctx) === sessionId) {
764
+ const active = activeTabId(ctx)
765
+ const activeTab = tabs.find((candidate) => candidate.tabId === active)
766
+ if (activeTab !== undefined) return activeTab
767
+ }
768
+ if (tabs.length === 1) return tabs[0]
769
+ throw new Error(`multiple browser tabs are open; pass tabId (${tabs.map((tab) => tab.tabId).join(', ')})`)
770
+ }
771
+
772
+ /**
773
+ * @param ctx - client plugin context.
774
+ * @returns the active tab id for the on-screen session, if any.
775
+ */
776
+ function activeTabId(ctx) {
777
+ try {
778
+ const record = ctx.sidebarRight?.active?.()
779
+ return typeof record?.id === 'string' ? record.id : undefined
780
+ } catch {
781
+ return undefined
782
+ }
783
+ }
784
+
785
+ /**
786
+ * Best-effort native facts for one tab.
787
+ * @param sessionId - owning conversation.
788
+ * @param tabId - sidebar tab id.
789
+ * @returns `{ url?, title? }`.
790
+ */
791
+ function nativeFacts(sessionId, tabId) {
792
+ const facts = {}
793
+ const webview = tryFindWebview(sessionId, tabId)
794
+ if (webview === undefined) return facts
795
+ try {
796
+ const url = typeof webview.getURL === 'function' ? webview.getURL() : ''
797
+ if (typeof url === 'string' && url !== '' && url !== 'about:blank') facts.url = url
798
+ } catch (error) {
799
+ void error
800
+ }
801
+ try {
802
+ const title = typeof webview.getTitle === 'function' ? webview.getTitle() : ''
803
+ if (typeof title === 'string' && title !== '') facts.title = title
804
+ } catch (error) {
805
+ void error
806
+ }
807
+ return facts
808
+ }
809
+
810
+ // ---------------------------------------------------------------------
811
+ // Transport relay helpers
812
+ // ---------------------------------------------------------------------
813
+
814
+ /**
815
+ * Viewport CSS-pixel point for one click request.
816
+ * @param webview - native guest.
817
+ * @param command - bridge command.
818
+ * @returns `{ x, y }` in guest viewport coordinates.
819
+ */
820
+ async function resolveClickPoint(webview, command) {
821
+ const arg = {
822
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
823
+ ...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
824
+ ...(typeof command.x === 'number' && typeof command.y === 'number' ? { x: command.x, y: command.y } : {}),
825
+ }
826
+ const resolved = await runInGuest(webview, guestResolveClickable, arg)
827
+ if (resolved === null || typeof resolved !== 'object' || resolved.ok !== true) {
828
+ throw new Error(resolved?.error ?? 'click target was not found')
829
+ }
830
+ return resolved
831
+ }
832
+
833
+ /**
834
+ * Execute one page-content command inside the native guest.
835
+ * @param ctx - client plugin context.
836
+ * @param command - bridge command.
837
+ * @returns bridge result `{ ok, value? , error? }`.
838
+ */
839
+ async function runPageCommand(ctx, command) {
840
+ const sessionId = typeof command.sessionId === 'string' ? command.sessionId : ''
841
+ if (sessionId === '') throw new Error('the host did not name a calling session')
842
+ assertPageSurface(sessionId)
843
+ const carrier = detectCarrier()
844
+ if (carrier.native !== true) {
845
+ throw new Error(`Page content control needs the Desktop native browser carrier (Electron webview); this build uses ${carrier.kind === 'web-iframe' ? 'a sandboxed cross-origin iframe' : 'an unknown carrier'} that page script cannot inspect. Tab management still works; use web_fetch to read page text.`)
846
+ }
847
+ const tab = resolveTab(ctx, sessionId, command.tabId)
848
+ const webview = findWebview(sessionId, tab.tabId)
849
+ await ensureGuestReady(webview)
850
+ const base = { sessionId, tabId: tab.tabId }
851
+
852
+ if (command.action === 'screenshot') {
853
+ const shot = await captureScreenshot(webview)
854
+ const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
855
+ return {
856
+ ok: true,
857
+ value: {
858
+ action: 'screenshot',
859
+ ...base,
860
+ url: typeof info.url === 'string' ? info.url : '',
861
+ title: typeof info.title === 'string' ? info.title : '',
862
+ width: shot.width,
863
+ height: shot.height,
864
+ mediaType: 'image/png',
865
+ base64: shot.base64,
866
+ },
867
+ }
868
+ }
869
+
870
+ if (command.action === 'read') {
871
+ const mode = typeof command.mode === 'string' ? command.mode : 'text'
872
+ if (mode === 'interactive') {
873
+ const info = await runInGuest(webview, guestInteractive, {
874
+ ...(typeof command.maxElements === 'number' ? { maxElements: command.maxElements } : {}),
875
+ })
876
+ const { ok: _interactiveOk, ...facts } = info
877
+ void _interactiveOk
878
+ return { ok: true, value: { action: 'read', ...base, mode, ...facts } }
879
+ }
880
+ if (mode === 'html') {
881
+ const info = await runInGuest(webview, guestReadHtml, {
882
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
883
+ ...(typeof command.maxChars === 'number' ? { maxChars: command.maxChars } : {}),
884
+ })
885
+ if (info.ok !== true) throw new Error(info.error ?? 'could not read page HTML')
886
+ const { ok: _interactiveOk, ...facts } = info
887
+ void _interactiveOk
888
+ return { ok: true, value: { action: 'read', ...base, mode, ...facts } }
889
+ }
890
+ const info = await runInGuest(webview, guestReadText, {
891
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
892
+ ...(typeof command.maxChars === 'number' ? { maxChars: command.maxChars } : {}),
893
+ })
894
+ if (info.ok !== true) throw new Error(info.error ?? 'could not read page text')
895
+ const { ok: _textOk, ...facts } = info
896
+ void _textOk
897
+ return { ok: true, value: { action: 'read', ...base, mode: 'text', ...facts } }
898
+ }
899
+
900
+ if (command.action === 'click') {
901
+ const resolved = await resolveClickPoint(webview, command)
902
+ await nativeClick(webview, resolved)
903
+ const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
904
+ return {
905
+ ok: true,
906
+ value: {
907
+ action: 'click',
908
+ ...base,
909
+ url: typeof info.url === 'string' ? info.url : '',
910
+ title: typeof info.title === 'string' ? info.title : '',
911
+ target: {
912
+ tag: resolved.tag,
913
+ ...(typeof resolved.label === 'string' && resolved.label !== '' ? { label: resolved.label } : {}),
914
+ ...(typeof resolved.selector === 'string' && resolved.selector !== '' ? { selector: resolved.selector } : {}),
915
+ x: resolved.x,
916
+ y: resolved.y,
917
+ },
918
+ },
919
+ }
920
+ }
921
+
922
+ if (command.action === 'type') {
923
+ const info = await runInGuest(webview, guestFocusAndType, {
924
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
925
+ text: String(command.text === undefined ? '' : command.text),
926
+ clear: command.clear !== false,
927
+ })
928
+ if (info.ok !== true) throw new Error(info.error ?? 'could not type into the page')
929
+ let submitted = false
930
+ if (command.submit === true) {
931
+ await nativeKey(webview, 'Enter')
932
+ submitted = true
933
+ }
934
+ const page = await runInGuest(webview, guestPageInfo).catch(() => ({}))
935
+ return {
936
+ ok: true,
937
+ value: {
938
+ action: 'type',
939
+ ...base,
940
+ url: typeof page.url === 'string' ? page.url : '',
941
+ title: typeof page.title === 'string' ? page.title : '',
942
+ target: { tag: info.tag, ...(typeof info.selector === 'string' && info.selector !== '' ? { selector: info.selector } : {}) },
943
+ typed: String(command.text === undefined ? '' : command.text).length,
944
+ submitted,
945
+ },
946
+ }
947
+ }
948
+
949
+ if (command.action === 'press') {
950
+ const code = await nativeKey(webview, command.key)
951
+ const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
952
+ return {
953
+ ok: true,
954
+ value: {
955
+ action: 'press',
956
+ ...base,
957
+ url: typeof info.url === 'string' ? info.url : '',
958
+ title: typeof info.title === 'string' ? info.title : '',
959
+ key: code,
960
+ },
961
+ }
962
+ }
963
+
964
+ if (command.action === 'scroll') {
965
+ const info = await runInGuest(webview, guestScroll, {
966
+ ...(typeof command.dx === 'number' ? { dx: command.dx } : {}),
967
+ ...(typeof command.dy === 'number' ? { dy: command.dy } : {}),
968
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
969
+ })
970
+ if (info.ok !== true) throw new Error(info.error ?? 'could not scroll the page')
971
+ const { ok: _scrollOk, ...facts } = info
972
+ void _scrollOk
973
+ return { ok: true, value: { action: 'scroll', ...base, ...facts } }
974
+ }
975
+
976
+ if (command.action === 'wait') {
977
+ const info = await runInGuest(webview, guestWait, {
978
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
979
+ ...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
980
+ ...(typeof command.timeoutMs === 'number' ? { timeoutMs: command.timeoutMs } : {}),
981
+ })
982
+ if (info.ok !== true) throw new Error(info.error ?? 'wait failed')
983
+ const page = await runInGuest(webview, guestPageInfo).catch(() => ({}))
984
+ return {
985
+ ok: true,
986
+ value: {
987
+ action: 'wait',
988
+ ...base,
989
+ url: typeof page.url === 'string' ? page.url : '',
990
+ title: typeof page.title === 'string' ? page.title : '',
991
+ matched: info.matched === true,
992
+ waitedMs: typeof info.waitedMs === 'number' ? info.waitedMs : 0,
993
+ ...(typeof command.selector === 'string' && command.selector !== '' ? { selector: command.selector } : {}),
994
+ ...(typeof command.contains === 'string' && command.contains !== '' ? { contains: command.contains } : {}),
995
+ },
996
+ }
997
+ }
998
+
999
+ if (command.action === 'eval') {
1000
+ const code = typeof command.code === 'string' ? command.code : ''
1001
+ if (code.trim() === '') throw new Error('eval needs a non-empty JavaScript code string')
1002
+ let raw
1003
+ try {
1004
+ raw = await withTimeout(
1005
+ Promise.resolve().then(() => webview.executeJavaScript(code, true)),
1006
+ TIMEOUTS.guest,
1007
+ 'The page script',
1008
+ )
1009
+ } catch (error) {
1010
+ if (messageOf(error).includes('did not finish within')) throw error
1011
+ throw new Error(`guest JavaScript failed: ${messageOf(error)}`)
1012
+ }
1013
+ let text
1014
+ try {
1015
+ const serialized = JSON.stringify(raw)
1016
+ text = serialized === undefined ? String(raw) : serialized
1017
+ } catch (error) {
1018
+ text = String(raw)
1019
+ void error
1020
+ }
1021
+ const truncated = text.length > MAX_EVAL_CHARS
1022
+ const info = await runInGuest(webview, guestPageInfo).catch(() => ({}))
1023
+ return {
1024
+ ok: true,
1025
+ value: {
1026
+ action: 'eval',
1027
+ ...base,
1028
+ url: typeof info.url === 'string' ? info.url : '',
1029
+ title: typeof info.title === 'string' ? info.title : '',
1030
+ result: truncated ? `${text.slice(0, MAX_EVAL_CHARS)} … (truncated)` : text,
1031
+ truncated,
1032
+ },
1033
+ }
1034
+ }
1035
+
1036
+ throw new Error(`unknown page action "${String(command.action)}"`)
1037
+ }
1038
+
1039
+ /**
1040
+ * Collect carrier and contract diagnostics for the `status` action.
1041
+ * @param ctx - client plugin context.
1042
+ * @param sessionId - calling conversation ('' when unknown).
1043
+ * @returns JSON-safe diagnostic facts.
1044
+ */
1045
+ function carrierChecks(ctx, sessionId) {
1046
+ const carrier = detectCarrier()
1047
+ const checks = {
1048
+ plugin: PLUGIN_VERSION,
1049
+ carrier: carrier.kind,
1050
+ native: carrier.native === true,
1051
+ webviews: document.querySelectorAll('webview').length,
1052
+ tabWrappers: document.querySelectorAll('[data-sidebar-right-tab]').length,
1053
+ }
1054
+ try {
1055
+ const configForms = ctx.get?.('configForms')
1056
+ const form = configForms?.get?.(CONFIG_ENTRY_ID)
1057
+ const snapshot = form?.getSnapshot?.()
1058
+ if (snapshot !== undefined) checks.configForm = snapshot.status
1059
+ } catch (error) {
1060
+ void error
1061
+ }
1062
+ const boot = globalThis.__DSH_BOOT__
1063
+ if (boot !== null && typeof boot === 'object' && boot.release !== null && typeof boot.release === 'object') {
1064
+ checks.harness = typeof boot.release.version === 'string' ? boot.release.version : 'unknown'
1065
+ if (typeof boot.release.commit === 'string') checks.commit = boot.release.commit
1066
+ }
1067
+ const mounted = mountedSession(ctx)
1068
+ const target = sessionId !== '' ? sessionId : (mounted ?? '')
1069
+ const surface = target === '' ? undefined : sessionRootOf(target)
1070
+ checks.surface = surface !== undefined
1071
+ if (surface !== undefined) {
1072
+ const wrappers = surface.querySelectorAll('[data-sidebar-right-tab]')
1073
+ checks.tabWrappers = wrappers.length
1074
+ let webview = null
1075
+ for (const wrapper of wrappers) {
1076
+ const found = wrapper.querySelector('webview[data-sidebar-browser-frame]')
1077
+ if (found !== null) {
1078
+ webview = found
1079
+ break
1080
+ }
1081
+ }
1082
+ checks.webview = webview !== null
1083
+ if (webview !== null) {
1084
+ const api = ['executeJavaScript', 'capturePage', 'sendInputEvent', 'loadURL', 'reload', 'getURL', 'getTitle']
1085
+ checks.api = api.filter((name) => typeof webview[name] === 'function')
1086
+ const missing = api.filter((name) => typeof webview[name] !== 'function')
1087
+ if (missing.length > 0) checks.apiMissing = missing
1088
+ try {
1089
+ const url = typeof webview.getURL === 'function' ? webview.getURL() : ''
1090
+ if (typeof url === 'string' && url !== '') checks.guestUrl = url
1091
+ } catch (error) {
1092
+ void error
1093
+ }
1094
+ try {
1095
+ checks.guestLoading = typeof webview.isLoading === 'function' ? webview.isLoading() === true : false
1096
+ } catch (error) {
1097
+ void error
1098
+ }
1099
+ }
1100
+ }
1101
+ return checks
1102
+ }
1103
+
1104
+ // ---------------------------------------------------------------------
1105
+ // Plugin row configuration page (Plugin Manager -> bundle -> row)
1106
+ // ---------------------------------------------------------------------
1107
+
1108
+ /** Locale namespace for this plugin's configuration page copy. */
1109
+ const UI_NS = 'sidebarBrowserAgent'
1110
+ /** Row this plugin's Host row is declared under (used as the settings namespace). */
1111
+ const CONFIG_ENTRY_ID = 'sidebar-browser-agent'
1112
+ /** Config page slot key: `<bundle package>#<row id>`. */
1113
+ const CONFIG_ROW_KEY = 'dsh-sidebar-browser-agent#sidebar-browser-agent'
1114
+
1115
+ /** English copy for the configuration page. */
1116
+ const CONFIG_EN = {
1117
+ 'config.summary': 'Configure the sidebar browser tools: enabled switches, allowed hosts and permissions.',
1118
+ 'config.heading': 'Sidebar browser agent',
1119
+ 'config.intro': 'Applies immediately to this profile. Page content work needs the DSH Desktop native browser carrier.',
1120
+ 'config.enabled': 'Enable agent tools',
1121
+ 'config.enabledHint': 'Register sidebar_browser and sidebar_page for agents in this profile.',
1122
+ 'config.hosts': 'Allowed hosts',
1123
+ 'config.hostsHint': 'One host per line; empty allows every HTTP(S) host. An entry also allows its subdomains; localhost:8765 matches that exact host and port.',
1124
+ 'config.hostsPlaceholder': 'example.com\nlocalhost:8765',
1125
+ 'config.allowEval': 'Allow eval',
1126
+ 'config.allowEvalHint': 'Run arbitrary JavaScript in the page (sidebar_page action "eval").',
1127
+ 'config.allowNativeInput': 'Allow native input',
1128
+ 'config.allowNativeInputHint': 'Native click, type and press actions (sidebar_page click/type/press).',
1129
+ 'config.save': 'Save',
1130
+ 'config.saving': 'Saving…',
1131
+ 'config.saved': 'Saved.',
1132
+ 'config.failed': 'The host rejected this change; the form was reloaded.',
1133
+ 'config.reset': 'Reset',
1134
+ 'config.loading': 'Loading configuration…',
1135
+ 'config.unavailable': 'Configuration is not editable in this profile (the settings service is unavailable).',
1136
+ 'config.readOnly': 'This profile is read-only.',
1137
+ 'config.readOnlyHint': 'The host does not accept configuration writes here.',
1138
+ }
1139
+
1140
+ /** Chinese copy for the configuration page. */
1141
+ const CONFIG_ZH = {
1142
+ 'config.summary': '配置侧栏浏览器工具:启用开关、域名白名单与权限。',
1143
+ 'config.heading': '侧栏浏览器 Agent 控制',
1144
+ 'config.intro': '修改立即对本 profile 生效。页面内容操作需要 DSH Desktop 原生浏览器载体。',
1145
+ 'config.enabled': '启用 Agent 工具',
1146
+ 'config.enabledHint': '为本 profile 中的 Agent 注册 sidebar_browser 与 sidebar_page。',
1147
+ 'config.hosts': '允许的域名',
1148
+ 'config.hostsHint': '每行一个;留空允许所有 HTTP(S) 域名。写 example.com 同时允许其子域;localhost:8765 精确匹配该主机与端口。',
1149
+ 'config.hostsPlaceholder': 'example.com\nlocalhost:8765',
1150
+ 'config.allowEval': '允许 eval',
1151
+ 'config.allowEvalHint': '允许在页面中执行任意 JavaScript(sidebar_page 的 eval 动作)。',
1152
+ 'config.allowNativeInput': '允许原生输入',
1153
+ 'config.allowNativeInputHint': '允许原生点击、输入与按键(sidebar_page 的 click/type/press)。',
1154
+ 'config.save': '保存',
1155
+ 'config.saving': '正在保存…',
1156
+ 'config.saved': '已保存。',
1157
+ 'config.failed': 'Host 拒绝了这次修改,表单已重新加载。',
1158
+ 'config.reset': '重置',
1159
+ 'config.loading': '正在加载配置…',
1160
+ 'config.unavailable': '此 profile 无法编辑配置(settings 服务不可用)。',
1161
+ 'config.readOnly': '此 profile 为只读。',
1162
+ 'config.readOnlyHint': 'Host 不接受在此写入配置。',
1163
+ }
1164
+
1165
+ const configStyles = {
1166
+ root: { display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '620px', color: 'var(--dsw-alias-label-primary)', fontSize: '13px', lineHeight: '20px' },
1167
+ heading: { margin: 0, fontSize: '15px', fontWeight: 600, lineHeight: '22px' },
1168
+ hint: { color: 'var(--dsw-alias-label-secondary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
1169
+ notice: { color: 'var(--dsw-alias-label-secondary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
1170
+ error: { color: 'var(--dsw-alias-state-error-primary)', margin: 0, fontSize: '12px', lineHeight: '18px' },
1171
+ field: { display: 'flex', flexDirection: 'column', gap: '4px' },
1172
+ checkRow: { display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' },
1173
+ checkText: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
1174
+ checkLabel: { fontWeight: 500 },
1175
+ textarea: {
1176
+ boxSizing: 'border-box',
1177
+ width: '100%',
1178
+ minHeight: '88px',
1179
+ padding: '8px 10px',
1180
+ color: 'var(--dsw-alias-label-primary)',
1181
+ background: 'var(--dsw-alias-bg-layer-1)',
1182
+ border: '0.5px solid var(--dsw-alias-border-l4)',
1183
+ borderRadius: '8px',
1184
+ font: 'inherit',
1185
+ resize: 'vertical',
1186
+ outline: 'none',
1187
+ },
1188
+ actions: { display: 'flex', alignItems: 'center', gap: '10px' },
1189
+ button: {
1190
+ height: '30px',
1191
+ padding: '0 14px',
1192
+ color: 'var(--dsw-alias-label-primary)',
1193
+ background: 'var(--dsw-alias-bg-layer-3)',
1194
+ border: '0.5px solid var(--dsw-alias-border-l4)',
1195
+ borderRadius: '8px',
1196
+ cursor: 'pointer',
1197
+ font: 'inherit',
1198
+ },
1199
+ primary: {
1200
+ height: '30px',
1201
+ padding: '0 14px',
1202
+ color: 'var(--dsw-alias-label-primary-foreground, #fff)',
1203
+ background: 'var(--dsw-alias-state-business-primary, #1f6feb)',
1204
+ border: '0.5px solid transparent',
1205
+ borderRadius: '8px',
1206
+ cursor: 'pointer',
1207
+ font: 'inherit',
1208
+ },
1209
+ }
1210
+
1211
+ /**
1212
+ * Render the allowlist as one host per line.
1213
+ * @param hosts - configured host entries.
1214
+ * @returns the textarea text.
1215
+ */
1216
+ function hostsToText(hosts) {
1217
+ return Array.isArray(hosts) ? hosts.join('\n') : ''
1218
+ }
1219
+
1220
+ /**
1221
+ * Parse the allowlist textarea.
1222
+ * @param text - comma or newline separated host entries.
1223
+ * @returns normalized, de-duplicated host entries.
1224
+ */
1225
+ function textToHosts(text) {
1226
+ const entries = String(text === undefined || text === null ? '' : text)
1227
+ .split(/[\n,]+/)
1228
+ .map((entry) => entry.trim().toLowerCase())
1229
+ .filter((entry) => entry !== '')
1230
+ return [...new Set(entries)]
1231
+ }
1232
+
1233
+ /**
1234
+ * Build the Host mutation operations for one staged draft.
1235
+ * @param draft - local form draft.
1236
+ * @param value - current Host values for the row.
1237
+ * @returns ordered `{ op: 'set', path, value }` operations (empty when unchanged).
1238
+ */
1239
+ function configOps(draft, value) {
1240
+ const current = value ?? {}
1241
+ const ops = []
1242
+ for (const field of ['enabled', 'allowEval', 'allowNativeInput']) {
1243
+ const next = draft[field] !== false
1244
+ const before = current[field] !== false
1245
+ if (next !== before) ops.push({ op: 'set', path: [field], value: next })
1246
+ }
1247
+ const nextHosts = textToHosts(draft.hostsText)
1248
+ const beforeHosts = hostsToText(current.allowedHosts).split('\n').map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== '')
1249
+ if (JSON.stringify(nextHosts) !== JSON.stringify(beforeHosts)) ops.push({ op: 'set', path: ['allowedHosts'], value: nextHosts })
1250
+ return ops
1251
+ }
1252
+
1253
+ /**
1254
+ * Build the local draft from one Host value snapshot.
1255
+ * @param value - projected volatile config.
1256
+ * @returns the draft.
1257
+ */
1258
+ function draftOf(value) {
1259
+ const current = value ?? {}
1260
+ return {
1261
+ enabled: current.enabled !== false,
1262
+ allowEval: current.allowEval !== false,
1263
+ allowNativeInput: current.allowNativeInput !== false,
1264
+ hostsText: hostsToText(current.allowedHosts),
1265
+ }
1266
+ }
1267
+
1268
+ /**
1269
+ * One switch row.
1270
+ * @param t - locale translate function.
1271
+ * @param options - label, hint, checked, disabled, onChange.
1272
+ * @returns the row element.
1273
+ */
1274
+ function switchRow(t, options) {
1275
+ return React.createElement('label', { style: configStyles.checkRow },
1276
+ React.createElement('input', {
1277
+ type: 'checkbox',
1278
+ checked: options.checked,
1279
+ disabled: options.disabled,
1280
+ onChange: (event) => options.onChange(event.target.checked),
1281
+ }),
1282
+ React.createElement('span', { style: configStyles.checkText },
1283
+ React.createElement('span', { style: configStyles.checkLabel }, t(options.label)),
1284
+ React.createElement('span', { style: configStyles.hint }, t(options.hint)),
1285
+ ),
1286
+ )
1287
+ }
1288
+
1289
+ /** The staged form for one row's volatile configuration. */
1290
+ function ConfigEditor(props) {
1291
+ const t = props.t
1292
+ const state = props.form.state
1293
+ const value = state.value ?? {}
1294
+ const [draft, setDraft] = React.useState(() => draftOf(value))
1295
+ const [busy, setBusy] = React.useState(false)
1296
+ const [notice, setNotice] = React.useState('')
1297
+ React.useEffect(() => {
1298
+ setDraft(draftOf(value))
1299
+ setNotice('')
1300
+ }, [state.revision, state.status])
1301
+ if (state.status === 'loading') return React.createElement('p', { style: configStyles.notice }, t('config.loading'))
1302
+ if (state.status !== 'ready' || value === undefined) return React.createElement('p', { style: configStyles.notice }, t('config.unavailable'))
1303
+ const readOnly = state.writable === false
1304
+ const ops = configOps(draft, value)
1305
+ const save = async () => {
1306
+ if (ops.length === 0 || busy) return
1307
+ setBusy(true)
1308
+ setNotice(t('config.saving'))
1309
+ try {
1310
+ const accepted = await props.form.mutate(ops, state.revision)
1311
+ setNotice(accepted === true ? t('config.saved') : t('config.failed'))
1312
+ } catch (error) {
1313
+ setNotice(`${t('config.failed')} (${error instanceof Error ? error.message : String(error)})`)
1314
+ } finally {
1315
+ setBusy(false)
1316
+ }
1317
+ }
1318
+ return React.createElement('div', { style: configStyles.root },
1319
+ React.createElement('h3', { style: configStyles.heading }, t('config.heading')),
1320
+ React.createElement('p', { style: configStyles.hint }, t('config.intro')),
1321
+ readOnly ? React.createElement('p', { style: configStyles.hint }, `${t('config.readOnly')} ${t('config.readOnlyHint')}`) : null,
1322
+ switchRow(t, { label: 'config.enabled', hint: 'config.enabledHint', checked: draft.enabled, disabled: readOnly, onChange: (next) => setDraft({ ...draft, enabled: next }) }),
1323
+ React.createElement('div', { style: configStyles.field },
1324
+ React.createElement('span', { style: configStyles.checkLabel }, t('config.hosts')),
1325
+ React.createElement('span', { style: configStyles.hint }, t('config.hostsHint')),
1326
+ React.createElement('textarea', {
1327
+ style: configStyles.textarea,
1328
+ rows: 4,
1329
+ spellCheck: false,
1330
+ disabled: readOnly,
1331
+ placeholder: t('config.hostsPlaceholder'),
1332
+ value: draft.hostsText,
1333
+ onChange: (event) => setDraft({ ...draft, hostsText: event.target.value }),
1334
+ }),
1335
+ ),
1336
+ switchRow(t, { label: 'config.allowEval', hint: 'config.allowEvalHint', checked: draft.allowEval, disabled: readOnly, onChange: (next) => setDraft({ ...draft, allowEval: next }) }),
1337
+ switchRow(t, { label: 'config.allowNativeInput', hint: 'config.allowNativeInputHint', checked: draft.allowNativeInput, disabled: readOnly, onChange: (next) => setDraft({ ...draft, allowNativeInput: next }) }),
1338
+ React.createElement('div', { style: configStyles.actions },
1339
+ React.createElement('button', {
1340
+ type: 'button',
1341
+ style: configStyles.primary,
1342
+ disabled: readOnly || busy || ops.length === 0,
1343
+ onClick: save,
1344
+ }, busy ? t('config.saving') : t('config.save')),
1345
+ React.createElement('button', {
1346
+ type: 'button',
1347
+ style: configStyles.button,
1348
+ disabled: readOnly || busy || ops.length === 0,
1349
+ onClick: () => { setDraft(draftOf(value)); setNotice('') },
1350
+ }, t('config.reset')),
1351
+ React.createElement('span', { style: notice.startsWith(t('config.failed')) ? configStyles.error : configStyles.notice }, notice),
1352
+ ),
1353
+ )
1354
+ }
1355
+
1356
+ /**
1357
+ * The `plugins.row.config` page: a summary line and the staged form.
1358
+ * @param props - `{ view, form, t }` from the Plugins page.
1359
+ * @returns text for the summary, the form for the page.
1360
+ */
1361
+ function ConfigPage(props) {
1362
+ if (props.view === 'summary') return props.t('config.summary')
1363
+ if (props.form === undefined) return React.createElement('p', { style: configStyles.hint }, props.t('config.unavailable'))
1364
+ return React.createElement(ConfigEditor, { form: props.form, t: props.t })
1365
+ }
1366
+
1367
+ // ---------------------------------------------------------------------
1368
+ // Command dispatch (tab management + page content)
1369
+ // ---------------------------------------------------------------------
1370
+
1371
+ const PAGE_ACTIONS = new Set(['read', 'click', 'type', 'press', 'scroll', 'eval', 'wait', 'screenshot'])
1372
+ /** Appended to results whose operation ran while another conversation was on screen. */
1373
+ const OFFSCREEN_NOTE = 'The calling conversation is not the one on screen; the operation ran on its hidden sidebar tab and is not visible right now.'
1374
+
1375
+ /**
1376
+ * Execute one bridge command through the sidebar controller.
1377
+ * @param ctx - client plugin context.
1378
+ * @param command - command frame from the host.
1379
+ * @param state - per-page memory (url of tabs this plugin opened).
1380
+ * @returns `{ ok, value? , error? }`; never throws.
1381
+ */
1382
+ async function executeCommand(ctx, command, state) {
1383
+ try {
1384
+ const sessionId = typeof command.sessionId === 'string' ? command.sessionId : ''
1385
+ const action = command.action
1386
+
1387
+ if (PAGE_ACTIONS.has(action)) {
1388
+ const result = await runPageCommand(ctx, command)
1389
+ if (result.ok === true && result.value !== null && typeof result.value === 'object' && mountedSession(ctx) !== command.sessionId) {
1390
+ result.value = { ...result.value, note: OFFSCREEN_NOTE }
1391
+ }
1392
+ return result
1393
+ }
1394
+
1395
+ if (action === 'status') {
1396
+ const mounted = mountedSession(ctx)
1397
+ const tabs = sessionId === '' ? allOpenTabs(ctx).filter((tab) => tab.kind === 'browser') : browserTabs(ctx, sessionId)
1398
+ const carrier = detectCarrier()
1399
+ const surface = ctx.get?.('sidebarRightTabs')
1400
+ const browserKind = surface?.get?.('browser') === undefined ? 'unknown' : 'registered'
1401
+ const checks = carrierChecks(ctx, sessionId)
1402
+ return {
1403
+ ok: true,
1404
+ value: {
1405
+ action: 'status',
1406
+ ...(sessionId === '' ? {} : { sessionId }),
1407
+ pageConnected: true,
1408
+ note: `right sidebar mounted=${mounted ?? 'none'}; carrier=${carrier.kind}; native page control=${carrier.native ? 'available' : 'unavailable'}; native browser tab type=${browserKind}; browser tabs=${tabs.length}; checks=${JSON.stringify(checks)}.`,
1409
+ },
1410
+ }
1411
+ }
1412
+
1413
+ if (sessionId === '') throw new Error('the host did not name a calling session')
1414
+
1415
+ if (action === 'list') {
1416
+ const tabs = browserTabs(ctx, sessionId)
1417
+ const mounted = mountedSession(ctx)
1418
+ const active = mounted === sessionId ? activeTabId(ctx) : undefined
1419
+ return {
1420
+ ok: true,
1421
+ value: {
1422
+ action: 'list',
1423
+ sessionId,
1424
+ tabs: tabs.map((tab) => {
1425
+ const facts = nativeFacts(sessionId, tab.tabId)
1426
+ const url = facts.url ?? (state.urls.has(tab.tabId) ? state.urls.get(tab.tabId) : undefined)
1427
+ return {
1428
+ tabId: tab.tabId,
1429
+ kind: tab.kind,
1430
+ ...(url === undefined ? {} : { url }),
1431
+ ...(facts.title === undefined ? {} : { title: facts.title }),
1432
+ ...(tab.tabId === active ? { active: true } : {}),
1433
+ }
1434
+ }),
1435
+ },
1436
+ }
1437
+ }
1438
+
1439
+ if (action === 'open') {
1440
+ requireMounted(ctx, sessionId)
1441
+ const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
1442
+ ctx.sidebarRight.openTab('browser', { params: { url: command.url } })
1443
+ const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
1444
+ if (created === undefined) throw new Error('the sidebar opened no new browser tab')
1445
+ state.urls.set(created.tabId, command.url)
1446
+ return {
1447
+ ok: true,
1448
+ value: { action: 'open', sessionId, tabId: created.tabId, url: command.url },
1449
+ }
1450
+ }
1451
+
1452
+ if (action === 'navigate') {
1453
+ const target = resolveTab(ctx, sessionId, command.tabId)
1454
+ const webview = tryFindWebview(sessionId, target.tabId)
1455
+ if (webview !== undefined && typeof webview.loadURL === 'function') {
1456
+ await withTimeout(
1457
+ Promise.resolve().then(() => webview.loadURL(command.url)),
1458
+ TIMEOUTS.navigation,
1459
+ 'The navigation',
1460
+ )
1461
+ state.urls.set(target.tabId, command.url)
1462
+ const note = mountedSession(ctx) === sessionId ? {} : { note: OFFSCREEN_NOTE }
1463
+ return { ok: true, value: { action: 'navigate', sessionId, tabId: target.tabId, url: command.url, ...note } }
1464
+ }
1465
+ requireMounted(ctx, sessionId)
1466
+ const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
1467
+ ctx.sidebarRight.openTab('browser', { params: { url: command.url }, replaceTab: target.tabId })
1468
+ const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
1469
+ const nextTabId = created === undefined ? target.tabId : created.tabId
1470
+ state.urls.delete(target.tabId)
1471
+ state.urls.set(nextTabId, command.url)
1472
+ return { ok: true, value: { action: 'navigate', sessionId, tabId: nextTabId, url: command.url } }
1473
+ }
1474
+
1475
+ if (action === 'reload') {
1476
+ const target = resolveTab(ctx, sessionId, command.tabId)
1477
+ const webview = tryFindWebview(sessionId, target.tabId)
1478
+ if (webview !== undefined && typeof webview.reload === 'function') {
1479
+ webview.reload()
1480
+ const facts = nativeFacts(sessionId, target.tabId)
1481
+ const note = mountedSession(ctx) === sessionId ? {} : { note: OFFSCREEN_NOTE }
1482
+ return {
1483
+ ok: true,
1484
+ value: { action: 'reload', sessionId, tabId: target.tabId, ...(facts.url === undefined ? {} : { url: facts.url }), ...note },
1485
+ }
1486
+ }
1487
+ requireMounted(ctx, sessionId)
1488
+ const url = state.urls.get(target.tabId)
1489
+ if (url === undefined) {
1490
+ throw new Error('This tab was not opened by the agent and has no native guest, so its current address is unknown; use action "navigate" with the target url instead.')
1491
+ }
1492
+ const before = new Set(browserTabs(ctx, sessionId).map((tab) => tab.tabId))
1493
+ ctx.sidebarRight.openTab('browser', { params: { url }, replaceTab: target.tabId })
1494
+ const created = browserTabs(ctx, sessionId).find((tab) => !before.has(tab.tabId))
1495
+ const nextTabId = created === undefined ? target.tabId : created.tabId
1496
+ state.urls.set(nextTabId, url)
1497
+ return { ok: true, value: { action: 'reload', sessionId, tabId: nextTabId, url } }
1498
+ }
1499
+
1500
+ if (action === 'focus') {
1501
+ requireMounted(ctx, sessionId)
1502
+ const target = resolveTab(ctx, sessionId, command.tabId)
1503
+ ctx.sidebarRight.focus(target.tabId)
1504
+ return { ok: true, value: { action, sessionId, tabId: target.tabId } }
1505
+ }
1506
+
1507
+ if (action === 'close') {
1508
+ requireMounted(ctx, sessionId)
1509
+ const target = resolveTab(ctx, sessionId, command.tabId)
1510
+ ctx.sidebarRight.close(target.tabId)
1511
+ state.urls.delete(target.tabId)
1512
+ return { ok: true, value: { action, sessionId, tabId: target.tabId } }
1513
+ }
1514
+
1515
+ return { ok: false, error: `unknown sidebar action "${String(action)}"` }
1516
+ } catch (error) {
1517
+ return { ok: false, error: messageOf(error) }
1518
+ }
1519
+ }
1520
+
1521
+ // ---------------------------------------------------------------------
1522
+ // Bridge
1523
+ // ---------------------------------------------------------------------
1524
+
1525
+ /**
1526
+ * Abortable pause.
1527
+ * @param ms - milliseconds.
1528
+ * @param signal - bridge cancellation.
1529
+ * @returns a promise that resolves after the delay.
1530
+ */
1531
+ function pause(ms, signal) {
1532
+ return new Promise((resolve) => {
1533
+ if (signal.aborted) {
1534
+ resolve()
1535
+ return
1536
+ }
1537
+ const timer = setTimeout(() => {
1538
+ signal.removeEventListener('abort', onAbort)
1539
+ resolve()
1540
+ }, ms)
1541
+ const onAbort = () => {
1542
+ clearTimeout(timer)
1543
+ resolve()
1544
+ }
1545
+ signal.addEventListener('abort', onAbort, { once: true })
1546
+ })
1547
+ }
1548
+
1549
+ /**
1550
+ * Start the long-poll bridge.
1551
+ * @param ctx - client plugin context.
1552
+ * @returns a disposer stopping the loop.
1553
+ */
1554
+ function startBridge(ctx) {
1555
+ const controller = new AbortController()
1556
+ const state = { urls: new Map() }
1557
+ const clientId = newClientId()
1558
+ let stopped = false
1559
+
1560
+ const report = (payload) => {
1561
+ if (stopped) return
1562
+ fetch(hostUrl(`${PLUGIN_ROOT}/report`), {
1563
+ method: 'POST',
1564
+ headers: { 'content-type': 'application/json' },
1565
+ body: JSON.stringify({ clientId, ...payload }),
1566
+ signal: controller.signal,
1567
+ }).catch(() => {})
1568
+ }
1569
+
1570
+ const sendResult = async (id, result) => {
1571
+ const body = JSON.stringify({
1572
+ id,
1573
+ ok: result !== null && typeof result === 'object' && result.ok === true,
1574
+ ...(result !== null && typeof result === 'object' && result.value !== undefined ? { value: result.value } : {}),
1575
+ ...(result !== null && typeof result === 'object' && typeof result.error === 'string' ? { error: result.error } : {}),
1576
+ })
1577
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1578
+ try {
1579
+ const response = await fetch(hostUrl(`${PLUGIN_ROOT}/result`), {
1580
+ method: 'POST',
1581
+ headers: { 'content-type': 'application/json' },
1582
+ body,
1583
+ signal: controller.signal,
1584
+ })
1585
+ if (response.ok) return
1586
+ } catch {
1587
+ /* retry once */
1588
+ }
1589
+ }
1590
+ }
1591
+
1592
+ const pollOnce = async () => {
1593
+ const mounted = mountedSession(ctx)
1594
+ const query = `?clientId=${encodeURIComponent(clientId)}&session=${encodeURIComponent(mounted ?? '')}`
1595
+ const response = await fetch(hostUrl(`${PLUGIN_ROOT}/poll`) + query, { signal: controller.signal })
1596
+ if (!response.ok) throw new Error(`bridge poll failed with HTTP ${response.status}`)
1597
+ const payload = await response.json()
1598
+ const commands = Array.isArray(payload?.commands) ? payload.commands : []
1599
+ for (const command of commands) {
1600
+ if (stopped) return
1601
+ if (command === null || typeof command !== 'object' || typeof command.id !== 'string') continue
1602
+ await sendResult(command.id, await executeCommand(ctx, command, state))
1603
+ }
1604
+ }
1605
+
1606
+ const loop = async () => {
1607
+ let failures = 0
1608
+ while (!stopped) {
1609
+ try {
1610
+ await pollOnce()
1611
+ failures = 0
1612
+ } catch (error) {
1613
+ if (stopped) return
1614
+ failures += 1
1615
+ if (failures === 1) report({ status: 'poll-failed', error: messageOf(error) })
1616
+ await pause(Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * failures), controller.signal)
1617
+ }
1618
+ }
1619
+ }
1620
+
1621
+ report({
1622
+ status: 'started',
1623
+ mounted: mountedSession(ctx) ?? '',
1624
+ carrier: detectCarrier().kind,
1625
+ href: typeof location === 'undefined' ? '' : location.href,
1626
+ })
1627
+ loop().catch((error) => {
1628
+ if (!stopped) report({ status: 'loop-failed', error: messageOf(error) })
1629
+ })
1630
+
1631
+ return () => {
1632
+ stopped = true
1633
+ controller.abort()
1634
+ }
1635
+ }
1636
+
1637
+ return {
1638
+ inject: ['sidebarRight', 'slots', 'locale'],
1639
+ apply(ctx) {
1640
+ ctx.effect(() => startBridge(ctx), 'sidebar-browser-agent: bridge')
1641
+ ctx.effect(() => ctx.locale.register(UI_NS, { en: CONFIG_EN, zh: CONFIG_ZH }), 'sidebar-browser-agent: config locale')
1642
+ ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
1643
+ name: 'plugins.row.config',
1644
+ key: CONFIG_ROW_KEY,
1645
+ locale: UI_NS,
1646
+ }, ConfigPage)), 'sidebar-browser-agent: config page')
1647
+ },
1648
+ __test: { hostsToText, textToHosts, configOps, draftOf, withTimeout, TIMEOUTS, CONFIG_ROW_KEY, CONFIG_ENTRY_ID },
1649
+ }
1650
+ },
1651
+ })