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.
@@ -0,0 +1,1181 @@
1
+ /**
2
+ * Host half of `dsh-sidebar-browser-agent`.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Register two model-facing tools:
6
+ * - `sidebar_browser` — tab management of the native right-sidebar browser;
7
+ * - `sidebar_page` — page content and interaction in a native browser tab.
8
+ * 2. Run the HTTP long-poll bridge carrying commands to the Web page half and
9
+ * answers back.
10
+ * 3. Turn a screenshot's base64 PNG into a durable image attachment so the
11
+ * model receives the actual image as a content block.
12
+ *
13
+ * The page half drives the native Electron guest (`executeJavaScript`,
14
+ * `capturePage`, `sendInputEvent`); this half never renders a page itself.
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+ import z from '../vendor/schemastery/index.mjs'
18
+
19
+ /** Plugin identity used by the bundle patch row. */
20
+ export const name = 'dsh-sidebar-browser-agent'
21
+
22
+ /** Plugin version reported by diagnostics; keep in sync with package.json. */
23
+ export const PLUGIN_VERSION = '0.2.6'
24
+
25
+ /** Services that must exist before this plugin mounts. */
26
+ export const inject = ['webServer', 'connection', 'tools']
27
+
28
+ /**
29
+ * Configuration schema shown by the Plugin Manager.
30
+ *
31
+ * This is a real native Schemastery schema (vendored, so a linked install
32
+ * needs no module resolution from the profile): the Loader validates and
33
+ * applies defaults, and Plugin Manager renders the fields for editing.
34
+ */
35
+ export const Config = z.object({
36
+ enabled: z.boolean().default(true).volatile().description('Register the sidebar_browser and sidebar_page agent tools.'),
37
+ allowedHosts: z.array(z.string()).default([]).volatile().description('Limit "open" and "navigate" to these hosts. An entry matches that exact host or a subdomain suffix; an empty list allows every HTTP(S) host.'),
38
+ allowEval: z.boolean().default(true).volatile().description('Allow sidebar_page action "eval" (arbitrary JavaScript in the page).'),
39
+ allowNativeInput: z.boolean().default(true).volatile().description('Allow sidebar_page click, type and press actions (native mouse and keyboard input).'),
40
+ })
41
+
42
+ /** Routes the page half talks to. */
43
+ const ROUTES = {
44
+ poll: '/plugin/sidebar-browser-agent/poll',
45
+ result: '/plugin/sidebar-browser-agent/result',
46
+ report: '/plugin/sidebar-browser-agent/report',
47
+ }
48
+
49
+ /** How long one poll request is held open while no command is pending. */
50
+ const POLL_HOLD_MS = 15_000
51
+ /** How long a tool call waits for the page's answer before failing. */
52
+ const RESULT_TIMEOUT_MS = 30_000
53
+ /** How long a tool call waits for a connected poller when none is parked. */
54
+ const CLIENT_WAIT_MS = 2_500
55
+ /** A client whose last poll is older than this is treated as disconnected. */
56
+ const CLIENT_STALE_MS = 5_000
57
+ /** Upper bound for a bridge request body (screenshots travel through it). */
58
+ const MAX_BODY_BYTES = 32 * 1024 * 1024
59
+ /** Hard cap on one screenshot payload. */
60
+ const MAX_SCREENSHOT_BASE64 = 12_000_000
61
+
62
+ /** Tab-management actions of `sidebar_browser`. */
63
+ const TAB_ACTIONS = ['status', 'list', 'open', 'navigate', 'reload', 'focus', 'close']
64
+ /** Actions that need an HTTP(S) address. */
65
+ const URL_ACTIONS = new Set(['open', 'navigate'])
66
+ /** Actions that need an existing tab id. */
67
+ const TAB_ID_ACTIONS = new Set(['navigate', 'reload', 'focus', 'close'])
68
+ /** Page-content actions of `sidebar_page`. */
69
+ const PAGE_ACTIONS = ['read', 'click', 'type', 'press', 'scroll', 'eval', 'wait', 'screenshot']
70
+ /** Read modes. */
71
+ const READ_MODES = ['text', 'interactive', 'html']
72
+
73
+ /**
74
+ * Write one JSON response unless the peer already went away.
75
+ * @param res - Node response owned by the route handler.
76
+ * @param status - HTTP status code.
77
+ * @param payload - JSON-serializable body.
78
+ */
79
+ function writeJson(res, status, payload) {
80
+ if (res.writableEnded || res.destroyed) return
81
+ const body = JSON.stringify(payload)
82
+ res.writeHead(status, {
83
+ 'content-type': 'application/json; charset=utf-8',
84
+ 'content-length': Buffer.byteLength(body),
85
+ 'cache-control': 'no-store',
86
+ })
87
+ res.end(body)
88
+ }
89
+
90
+ /**
91
+ * Read a bounded request body as UTF-8 text.
92
+ * @param req - Node request.
93
+ * @param limit - maximum accepted byte count.
94
+ * @returns the decoded body.
95
+ */
96
+ function readBody(req, limit) {
97
+ return new Promise((resolve, reject) => {
98
+ const chunks = []
99
+ let size = 0
100
+ req.on('data', (chunk) => {
101
+ size += chunk.length
102
+ if (size > limit) {
103
+ reject(new Error('request body is too large'))
104
+ req.destroy()
105
+ return
106
+ }
107
+ chunks.push(chunk)
108
+ })
109
+ req.on('end', () => {
110
+ resolve(Buffer.concat(chunks).toString('utf8'))
111
+ })
112
+ req.on('error', reject)
113
+ })
114
+ }
115
+
116
+ /**
117
+ * Build the error a cancellation raises from a tool call.
118
+ * @param reason - abort reason when the caller supplied one.
119
+ * @returns an Error carrying the usual aborted message.
120
+ */
121
+ function abortError(reason) {
122
+ const error = new Error('the tool call was aborted')
123
+ error.name = 'AbortError'
124
+ if (reason !== undefined) error.cause = reason
125
+ return error
126
+ }
127
+
128
+ /**
129
+ * Sleep that also settles when the caller aborts.
130
+ * @param ms - milliseconds to wait.
131
+ * @param signal - optional caller signal.
132
+ */
133
+ function delay(ms, signal) {
134
+ return new Promise((resolve, reject) => {
135
+ const finish = () => {
136
+ clearTimeout(timer)
137
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
138
+ }
139
+ const timer = setTimeout(() => {
140
+ finish()
141
+ resolve()
142
+ }, ms)
143
+ const onAbort = () => {
144
+ finish()
145
+ reject(abortError(signal.reason))
146
+ }
147
+ if (signal !== undefined) {
148
+ if (signal.aborted) {
149
+ finish()
150
+ reject(abortError(signal.reason))
151
+ return
152
+ }
153
+ signal.addEventListener('abort', onAbort, { once: true })
154
+ }
155
+ })
156
+ }
157
+
158
+ /**
159
+ * Validate and normalize one HTTP(S) address.
160
+ * @param raw - model-supplied address.
161
+ * @param action - action asking for it, used in the failure text.
162
+ * @returns the normalized absolute URL string.
163
+ */
164
+ function normalizeHttpUrl(raw, action) {
165
+ if (typeof raw !== 'string' || raw.trim() === '') {
166
+ throw new Error(`action "${action}" requires an http:// or https:// url`)
167
+ }
168
+ let parsed
169
+ try {
170
+ parsed = new URL(raw.trim())
171
+ } catch {
172
+ throw new Error(`"${raw}" is not a valid URL`)
173
+ }
174
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
175
+ throw new Error('the sidebar browser only opens http:// and https:// URLs')
176
+ }
177
+ return parsed.toString()
178
+ }
179
+
180
+ /**
181
+ * Normalize the validated plugin configuration into the exact shape used at runtime.
182
+ * @param config - config object handed to apply (defaults already applied by the loader).
183
+ * @returns the resolved switches.
184
+ */
185
+ function fieldValue(field, fallback) {
186
+ if (field !== null && typeof field === 'object' && typeof field.get === 'function') return field.get()
187
+ if (field === undefined) return fallback
188
+ return field
189
+ }
190
+
191
+ /**
192
+ * Read the switch values currently in force.
193
+ *
194
+ * The declaration marks every field `.volatile()`, so the Loader hands the
195
+ * plugin stable references; editing the row in Plugin Manager (or the profile
196
+ * patch) updates those references without restarting the plugin.
197
+ * @param config - raw plugin config from `apply` (volatile fields are references).
198
+ * @returns the resolved switches.
199
+ */
200
+ function liveConfig(config) {
201
+ const raw = config === null || typeof config !== 'object' ? {} : config
202
+ const hosts = fieldValue(raw.allowedHosts, [])
203
+ return {
204
+ enabled: fieldValue(raw.enabled, true) !== false,
205
+ allowEval: fieldValue(raw.allowEval, true) !== false,
206
+ allowNativeInput: fieldValue(raw.allowNativeInput, true) !== false,
207
+ allowedHosts: Array.isArray(hosts)
208
+ ? hosts.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim().toLowerCase())
209
+ : [],
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Enforce the configured host allowlist for one navigation.
215
+ * @param url - normalized absolute HTTP(S) URL.
216
+ * @param config - resolved switches.
217
+ */
218
+ function assertHostAllowed(url, config) {
219
+ if (config.allowedHosts.length === 0) return
220
+ const parsed = new URL(url)
221
+ const host = parsed.host.toLowerCase()
222
+ const hostname = parsed.hostname.toLowerCase()
223
+ const allowed = config.allowedHosts.some((entry) => {
224
+ const candidate = entry.startsWith('*.') ? entry.slice(2) : entry
225
+ return host === candidate || hostname === candidate || hostname.endsWith(`.${candidate}`)
226
+ })
227
+ if (!allowed) {
228
+ throw new Error(`The plugin configuration does not allow host "${parsed.host}"; allowed hosts: ${config.allowedHosts.join(', ')}`)
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Join non-empty diagnostic notes.
234
+ * @param parts - note fragments.
235
+ * @returns one note string.
236
+ */
237
+ function joinNotes(parts) {
238
+ return parts.filter((part) => typeof part === 'string' && part !== '').join('; ')
239
+ }
240
+
241
+ /**
242
+ * Host-side facts appended to status notes.
243
+ * @param ctx - host plugin context.
244
+ * @param config - resolved switches.
245
+ * @returns the host diagnostic fragment.
246
+ */
247
+ function hostFacts(ctx, config) {
248
+ const attachments = ctx.get('attachments')
249
+ const attachmentsReady = attachments !== undefined && typeof attachments.saveImage === 'function'
250
+ return `host-plugin=${PLUGIN_VERSION}; config=${JSON.stringify(liveConfig(config))}; attachments=${attachmentsReady ? 'available' : 'missing (screenshots unavailable)'}`
251
+ }
252
+
253
+ /**
254
+ * Startup readiness check: verify the services the tools rely on.
255
+ * @param ctx - host plugin context.
256
+ * @param config - resolved switches.
257
+ * @returns the list of problems found.
258
+ */
259
+ function selfCheck(ctx, config) {
260
+ const current = liveConfig(config)
261
+ const problems = []
262
+ const webServer = ctx.get('webServer')
263
+ if (webServer === undefined || typeof webServer.register !== 'function') problems.push('webServer service is missing route registration')
264
+ const connection = ctx.get('connection')
265
+ if (connection === undefined || typeof connection.requestRejection !== 'function') problems.push('connection service is missing requestRejection')
266
+ if (ctx.get('tools') === undefined) problems.push('tools service is missing')
267
+ const attachments = ctx.get('attachments')
268
+ if (attachments === undefined || typeof attachments.saveImage !== 'function') problems.push('attachments service is missing; screenshots will be unavailable')
269
+ const summary = `sidebar-browser-agent ${PLUGIN_VERSION}: ready (enabled=${current.enabled}, allowEval=${current.allowEval}, allowNativeInput=${current.allowNativeInput}, allowedHosts=${current.allowedHosts.length})`
270
+ const logger = ctx.logger
271
+ if (problems.length > 0) {
272
+ if (typeof logger?.warn === 'function') logger.warn(`${summary}; self-check: ${problems.join('; ')}`)
273
+ } else if (typeof logger?.info === 'function') {
274
+ logger.info(summary)
275
+ }
276
+ return problems
277
+ }
278
+
279
+ /**
280
+ * Require one model argument to be a string.
281
+ * @param value - candidate value.
282
+ * @param name - parameter name used in the failure text.
283
+ * @param optional - whether absence is allowed.
284
+ * @returns the trimmed string or undefined.
285
+ */
286
+ function requireString(value, name, optional = false) {
287
+ if (value === undefined || value === null) {
288
+ if (optional) return undefined
289
+ throw new Error(`this action requires "${name}"`)
290
+ }
291
+ if (typeof value !== 'string') throw new Error(`"${name}" must be a string`)
292
+ const trimmed = value.trim()
293
+ if (trimmed === '') {
294
+ if (optional) return undefined
295
+ throw new Error(`"${name}" must be a non-empty string`)
296
+ }
297
+ return trimmed
298
+ }
299
+
300
+ /**
301
+ * @param value - candidate value.
302
+ * @param name - parameter name used in the failure text.
303
+ * @param min - inclusive lower bound.
304
+ * @param max - inclusive upper bound.
305
+ * @param fallback - value to use when the argument is absent.
306
+ * @returns the bounded number.
307
+ */
308
+ function boundedNumber(value, name, min, max, fallback) {
309
+ if (value === undefined || value === null) return fallback
310
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`"${name}" must be a finite number`)
311
+ return Math.min(max, Math.max(min, Math.round(value)))
312
+ }
313
+
314
+ /** One parked poll request waiting for commands. */
315
+ class PollWaiter {
316
+ /**
317
+ * @param clientId - page instance that parked the request.
318
+ * @param session - session the page currently shows ('' when none).
319
+ * @param respond - writes the command list to the parked response.
320
+ */
321
+ constructor(clientId, session, respond) {
322
+ this.clientId = clientId
323
+ this.session = session
324
+ this.respond = respond
325
+ this.timer = undefined
326
+ this.answered = false
327
+ }
328
+
329
+ /**
330
+ * Answer exactly once.
331
+ * @param commands - commands delivered to this poll.
332
+ */
333
+ answer(commands) {
334
+ if (this.answered) return
335
+ this.answered = true
336
+ clearTimeout(this.timer)
337
+ this.respond(commands)
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Cross-page command bridge for both tools.
343
+ *
344
+ * One process-wide instance owns parked polls, the command queue, and pending
345
+ * tool calls. Multiple pages are supported best-effort: a command is handed to
346
+ * a page showing the calling session when one is parked, otherwise to the
347
+ * first parked page.
348
+ */
349
+ class Bridge {
350
+ /**
351
+ * @param ctx - host plugin context (carries webServer and connection).
352
+ */
353
+ constructor(ctx) {
354
+ this.ctx = ctx
355
+ /** @type {Map<string, PollWaiter>} parked polls by page instance id */
356
+ this.waiters = new Map()
357
+ /** @type {Array<object>} commands waiting for a poll */
358
+ this.queue = []
359
+ /** @type {Map<string, { settle: (result: object) => void }>} in-flight tool calls */
360
+ this.pending = new Map()
361
+ /** @type {number} time of the most recent poll request */
362
+ this.lastPollAt = 0
363
+ /** @type {object | null} last `started` report from a page */
364
+ this.lastHello = null
365
+ /** @type {string | null} last browser-half error report */
366
+ this.lastError = null
367
+ this.disposed = false
368
+ }
369
+
370
+ /** Whether a page is currently parked on a poll. */
371
+ get connected() {
372
+ return this.waiters.size > 0
373
+ }
374
+
375
+ /**
376
+ * Register the three bridge routes.
377
+ * @returns a disposer removing them.
378
+ */
379
+ registerRoutes() {
380
+ const disposers = [
381
+ this.ctx.webServer.register({
382
+ kind: 'exact',
383
+ path: ROUTES.poll,
384
+ handler: (req, res) => this.handlePoll(req, res),
385
+ }),
386
+ this.ctx.webServer.register({
387
+ kind: 'exact',
388
+ path: ROUTES.result,
389
+ handler: (req, res) => this.handleResult(req, res),
390
+ }),
391
+ this.ctx.webServer.register({
392
+ kind: 'exact',
393
+ path: ROUTES.report,
394
+ handler: (req, res) => this.handleReport(req, res),
395
+ }),
396
+ ]
397
+ return () => {
398
+ for (const dispose of disposers) dispose()
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Admit or refuse one browser request.
404
+ * @param req - Node request.
405
+ * @returns true when the request may be served.
406
+ */
407
+ fence(req) {
408
+ try {
409
+ return this.ctx.connection.requestRejection(req) === undefined
410
+ } catch {
411
+ return false
412
+ }
413
+ }
414
+
415
+ /**
416
+ * `GET /poll`: hand over queued commands or park until one arrives.
417
+ * @param req - Node request carrying `clientId` and the shown `session`.
418
+ * @param res - Node response.
419
+ */
420
+ handlePoll(req, res) {
421
+ if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
422
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
423
+ if (this.disposed) return writeJson(res, 503, { ok: false, error: 'plugin disposed' })
424
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
425
+ const clientId = (url.searchParams.get('clientId') ?? '').trim()
426
+ if (clientId === '') return writeJson(res, 400, { ok: false, error: 'clientId is required' })
427
+ const session = url.searchParams.get('session') ?? ''
428
+ this.lastPollAt = Date.now()
429
+
430
+ const immediate = this.queue.splice(0, this.queue.length)
431
+ if (immediate.length > 0) return writeJson(res, 200, { commands: immediate })
432
+
433
+ const previous = this.waiters.get(clientId)
434
+ if (previous !== undefined) {
435
+ this.waiters.delete(clientId)
436
+ previous.answer([])
437
+ }
438
+ const waiter = new PollWaiter(clientId, session, (commands) => {
439
+ writeJson(res, 200, { commands })
440
+ })
441
+ waiter.timer = setTimeout(() => {
442
+ if (this.waiters.get(clientId) === waiter) this.waiters.delete(clientId)
443
+ waiter.answer([])
444
+ }, POLL_HOLD_MS)
445
+ this.waiters.set(clientId, waiter)
446
+ res.on('close', () => {
447
+ if (this.waiters.get(clientId) === waiter) this.waiters.delete(clientId)
448
+ })
449
+ }
450
+
451
+ /**
452
+ * `POST /result`: settle one in-flight tool call.
453
+ * @param req - Node request carrying `{ id, ok, value?, error? }`.
454
+ * @param res - Node response.
455
+ */
456
+ async handleResult(req, res) {
457
+ if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
458
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
459
+ let body
460
+ try {
461
+ body = JSON.parse(await readBody(req, MAX_BODY_BYTES))
462
+ } catch (error) {
463
+ return writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid body' })
464
+ }
465
+ if (body === null || typeof body !== 'object') {
466
+ return writeJson(res, 400, { ok: false, error: 'invalid body' })
467
+ }
468
+ const id = typeof body.id === 'string' ? body.id : ''
469
+ const pending = this.pending.get(id)
470
+ if (pending === undefined) return writeJson(res, 200, { ok: false, error: 'unknown command' })
471
+ this.pending.delete(id)
472
+ pending.settle({
473
+ ok: body.ok === true,
474
+ value: body.value,
475
+ error: typeof body.error === 'string' && body.error !== '' ? body.error : undefined,
476
+ })
477
+ return writeJson(res, 200, { ok: true })
478
+ }
479
+
480
+ /**
481
+ * `POST /report`: record a browser-half lifecycle report for diagnostics.
482
+ * @param req - Node request carrying `{ status, mounted?, carrier?, href?, error? }`.
483
+ * @param res - Node response.
484
+ */
485
+ async handleReport(req, res) {
486
+ if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
487
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
488
+ let body
489
+ try {
490
+ body = JSON.parse(await readBody(req, MAX_BODY_BYTES))
491
+ } catch (error) {
492
+ return writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid body' })
493
+ }
494
+ if (body === null || typeof body !== 'object') return writeJson(res, 400, { ok: false, error: 'invalid body' })
495
+ if (body.status === 'started') {
496
+ this.lastHello = {
497
+ reportedAt: Date.now(),
498
+ mounted: typeof body.mounted === 'string' ? body.mounted : '',
499
+ carrier: typeof body.carrier === 'string' ? body.carrier : '',
500
+ href: typeof body.href === 'string' ? body.href : '',
501
+ }
502
+ this.lastError = null
503
+ } else if (typeof body.error === 'string' && body.error !== '') {
504
+ this.lastError = body.error
505
+ }
506
+ return writeJson(res, 200, { ok: true })
507
+ }
508
+
509
+ /**
510
+ * Describe why no page half answered.
511
+ * @returns the failure text handed to the model.
512
+ */
513
+ noClientMessage() {
514
+ const detail = this.lastError !== null
515
+ ? ` Last browser-half error: ${this.lastError}`
516
+ : this.lastHello === null
517
+ ? ' The plugin\'s browser half never reported in; check Settings > Plugins for a client-side load failure, then reload the Harness page.'
518
+ : ` Last browser-half report: carrier=${this.lastHello.carrier || 'unknown'}, mounted=${this.lastHello.mounted === '' ? 'none' : this.lastHello.mounted}, page=${this.lastHello.href}.`
519
+ return `The Harness Web page is not connected to the sidebar browser bridge. Open or reload the Harness page in this profile and retry.${detail}`
520
+ }
521
+
522
+ /**
523
+ * Wait until some page is parked on a poll.
524
+ * @param signal - caller cancellation.
525
+ */
526
+ async ensureClient(signal) {
527
+ if (this.connected) return
528
+ if (Date.now() - this.lastPollAt > CLIENT_STALE_MS) throw new Error(this.noClientMessage())
529
+ const deadline = Date.now() + CLIENT_WAIT_MS
530
+ while (!this.connected && Date.now() < deadline) await delay(50, signal)
531
+ if (!this.connected) throw new Error(this.noClientMessage())
532
+ }
533
+
534
+ /**
535
+ * Deliver one command to a parked poll, or queue it for the next one.
536
+ * @param entry - command frame including its id.
537
+ * @param sessionId - session the page should show.
538
+ */
539
+ deliver(entry, sessionId) {
540
+ const parked = [...this.waiters.values()]
541
+ const preferred = parked.find((waiter) => waiter.session === sessionId) ?? parked[0]
542
+ if (preferred !== undefined) {
543
+ this.waiters.delete(preferred.clientId)
544
+ preferred.answer([entry])
545
+ return
546
+ }
547
+ this.queue.push(entry)
548
+ }
549
+
550
+ /**
551
+ * Send one command to the page half and await its answer.
552
+ * @param command - command fields without id.
553
+ * @param signal - caller cancellation.
554
+ * @returns `{ ok, value?, error? }` from the page.
555
+ */
556
+ async dispatch(command, signal) {
557
+ await this.ensureClient(signal)
558
+ const id = randomUUID()
559
+ const entry = { id, ...command }
560
+ return await new Promise((resolve, reject) => {
561
+ const settle = (result) => {
562
+ clearTimeout(timer)
563
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
564
+ resolve(result)
565
+ }
566
+ const fail = (error) => {
567
+ clearTimeout(timer)
568
+ if (signal !== undefined) signal.removeEventListener('abort', onAbort)
569
+ reject(error)
570
+ }
571
+ const timer = setTimeout(() => {
572
+ if (this.pending.delete(id)) fail(new Error(`The Harness Web page did not answer within ${Math.round(RESULT_TIMEOUT_MS / 1000)}s; retry once the page is focused.`))
573
+ }, RESULT_TIMEOUT_MS)
574
+ const onAbort = () => {
575
+ if (this.pending.delete(id)) fail(abortError(signal?.reason))
576
+ }
577
+ if (signal !== undefined) {
578
+ if (signal.aborted) {
579
+ fail(abortError(signal.reason))
580
+ return
581
+ }
582
+ signal.addEventListener('abort', onAbort, { once: true })
583
+ }
584
+ this.pending.set(id, { settle })
585
+ this.deliver(entry, command.sessionId)
586
+ })
587
+ }
588
+
589
+ /** Drop parked polls, queued commands and in-flight calls (plugin unload). */
590
+ dispose() {
591
+ this.disposed = true
592
+ for (const waiter of this.waiters.values()) waiter.answer([])
593
+ this.waiters.clear()
594
+ this.queue = []
595
+ for (const [id, pending] of this.pending) {
596
+ this.pending.delete(id)
597
+ pending.settle({ ok: false, error: 'the sidebar browser agent plugin was unloaded' })
598
+ }
599
+ }
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Tool schemas
604
+ // ---------------------------------------------------------------------------
605
+
606
+ /** Parameter JSON Schema of `sidebar_browser`. */
607
+ const BROWSER_PARAMETERS = {
608
+ type: 'object',
609
+ properties: {
610
+ action: {
611
+ type: 'string',
612
+ enum: TAB_ACTIONS,
613
+ description: 'status | list | open | navigate | reload | focus | close.',
614
+ },
615
+ url: {
616
+ type: 'string',
617
+ description: 'HTTP(S) address for "open" (new tab) and "navigate" (load into the current tab).',
618
+ },
619
+ tabId: {
620
+ type: 'string',
621
+ description: 'Target tab id from "list" for navigate, reload, focus and close.',
622
+ },
623
+ },
624
+ required: ['action'],
625
+ }
626
+
627
+ /** Output JSON Schema of `sidebar_browser`. */
628
+ const BROWSER_OUTPUT = {
629
+ type: 'object',
630
+ additionalProperties: false,
631
+ properties: {
632
+ action: { type: 'string', description: 'The action that ran.' },
633
+ sessionId: { type: 'string', description: 'Calling conversation session id.' },
634
+ tabId: { type: 'string', description: 'The native browser tab affected or created.' },
635
+ url: { type: 'string', description: 'The HTTP(S) address involved.' },
636
+ pageConnected: { type: 'boolean', description: 'Whether the Web page half answered (status only).' },
637
+ note: { type: 'string', description: 'Carrier and bridge diagnostics (status only).' },
638
+ tabs: {
639
+ type: 'array',
640
+ description: 'Open native sidebar browser tabs (list only).',
641
+ items: {
642
+ type: 'object',
643
+ additionalProperties: false,
644
+ properties: {
645
+ tabId: { type: 'string' },
646
+ kind: { type: 'string' },
647
+ url: { type: 'string' },
648
+ title: { type: 'string' },
649
+ active: { type: 'boolean' },
650
+ },
651
+ },
652
+ },
653
+ },
654
+ required: ['action'],
655
+ }
656
+
657
+ /** Parameter JSON Schema of `sidebar_page`. */
658
+ const PAGE_PARAMETERS = {
659
+ type: 'object',
660
+ properties: {
661
+ action: {
662
+ type: 'string',
663
+ enum: PAGE_ACTIONS,
664
+ description: 'read | click | type | press | scroll | eval | wait | screenshot.',
665
+ },
666
+ tabId: {
667
+ type: 'string',
668
+ description: 'Target browser tab id; omit to use the active browser tab of the calling conversation.',
669
+ },
670
+ mode: {
671
+ type: 'string',
672
+ enum: READ_MODES,
673
+ description: 'read only: "text" visible text (default), "interactive" clickable elements, "html" raw markup.',
674
+ },
675
+ selector: {
676
+ type: 'string',
677
+ description: 'CSS selector for read (scope), click, type, scroll (into view) and wait.',
678
+ },
679
+ contains: {
680
+ type: 'string',
681
+ description: 'Case-insensitive visible-text match for click and wait.',
682
+ },
683
+ text: { type: 'string', description: 'type only: text to write into the field.' },
684
+ clear: { type: 'boolean', description: 'type only: replace the field value first (default true).' },
685
+ submit: { type: 'boolean', description: 'type only: press Enter after typing (default false).' },
686
+ key: {
687
+ type: 'string',
688
+ description: 'press only: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, PageUp/Down, Home, End, Space.',
689
+ },
690
+ code: { type: 'string', description: 'eval only: JavaScript to execute in the page; returns its JSON result.' },
691
+ dx: { type: 'number', description: 'scroll only: horizontal pixels (positive scrolls right).' },
692
+ dy: { type: 'number', description: 'scroll only: vertical pixels (positive scrolls down).' },
693
+ x: { type: 'number', description: 'click only: viewport x coordinate when clicking by position.' },
694
+ y: { type: 'number', description: 'click only: viewport y coordinate when clicking by position.' },
695
+ maxChars: { type: 'number', description: 'read only: character cap for text/html (default 12000/30000).' },
696
+ maxElements: { type: 'number', description: 'read interactive only: element cap (default 200, max 500).' },
697
+ timeoutMs: { type: 'number', description: 'wait only: maximum wait in milliseconds (default 5000, max 15000).' },
698
+ },
699
+ required: ['action'],
700
+ }
701
+
702
+ /** Output JSON Schema of `sidebar_page`. */
703
+ const PAGE_OUTPUT = {
704
+ type: 'object',
705
+ additionalProperties: false,
706
+ properties: {
707
+ action: { type: 'string', description: 'The action that ran.' },
708
+ sessionId: { type: 'string', description: 'Calling conversation session id.' },
709
+ tabId: { type: 'string', description: 'Native browser tab used.' },
710
+ url: { type: 'string', description: 'Page address after the action.' },
711
+ title: { type: 'string', description: 'Page title after the action.' },
712
+ mode: { type: 'string', description: 'read only: the requested mode.' },
713
+ text: { type: 'string', description: 'read text: visible page text.' },
714
+ html: { type: 'string', description: 'read html: page markup.' },
715
+ totalChars: { type: 'integer', description: 'read only: characters before the cap.' },
716
+ truncated: { type: 'boolean', description: 'read/eval only: whether the result hit its cap.' },
717
+ viewport: {
718
+ type: 'object',
719
+ additionalProperties: false,
720
+ properties: { width: { type: 'integer' }, height: { type: 'integer' } },
721
+ },
722
+ scroll: {
723
+ type: 'object',
724
+ additionalProperties: false,
725
+ properties: { x: { type: 'integer' }, y: { type: 'integer' }, height: { type: 'integer' } },
726
+ },
727
+ elements: {
728
+ type: 'array',
729
+ description: 'read interactive: visible interactive elements.',
730
+ items: {
731
+ type: 'object',
732
+ additionalProperties: false,
733
+ properties: {
734
+ index: { type: 'integer' },
735
+ tag: { type: 'string' },
736
+ type: { type: 'string' },
737
+ label: { type: 'string' },
738
+ href: { type: 'string' },
739
+ selector: { type: 'string' },
740
+ x: { type: 'integer' },
741
+ y: { type: 'integer' },
742
+ disabled: { type: 'boolean' },
743
+ },
744
+ },
745
+ },
746
+ target: {
747
+ type: 'object',
748
+ additionalProperties: false,
749
+ properties: {
750
+ tag: { type: 'string' },
751
+ label: { type: 'string' },
752
+ selector: { type: 'string' },
753
+ x: { type: 'integer' },
754
+ y: { type: 'integer' },
755
+ },
756
+ },
757
+ typed: { type: 'integer', description: 'type only: characters written.' },
758
+ submitted: { type: 'boolean', description: 'type only: whether Enter was pressed.' },
759
+ key: { type: 'string', description: 'press only: the key code dispatched.' },
760
+ scrollX: { type: 'integer' },
761
+ scrollY: { type: 'integer' },
762
+ scrollHeight: { type: 'integer' },
763
+ viewportHeight: { type: 'integer' },
764
+ matched: { type: 'boolean', description: 'wait only: whether the condition became true.' },
765
+ waitedMs: { type: 'integer', description: 'wait only: elapsed milliseconds.' },
766
+ selector: { type: 'string', description: 'wait only: the selector waited for.' },
767
+ contains: { type: 'string', description: 'wait only: the text waited for.' },
768
+ result: { type: 'string', description: 'eval only: JSON text of the script result.' },
769
+ note: { type: 'string', description: 'Extra context; present when the operation ran while another conversation was on screen.' },
770
+ width: { type: 'integer', description: 'screenshot only: image width.' },
771
+ height: { type: 'integer', description: 'screenshot only: image height.' },
772
+ image: {
773
+ type: 'object',
774
+ description: 'screenshot only: durable image attachment reference.',
775
+ additionalProperties: false,
776
+ properties: {
777
+ attachmentId: { type: 'string' },
778
+ mediaType: { type: 'string' },
779
+ bytes: { type: 'integer' },
780
+ width: { type: 'integer' },
781
+ height: { type: 'integer' },
782
+ name: { type: 'string' },
783
+ },
784
+ required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
785
+ },
786
+ },
787
+ required: ['action'],
788
+ }
789
+
790
+ // ---------------------------------------------------------------------------
791
+ // Tool descriptions and rendering
792
+ // ---------------------------------------------------------------------------
793
+
794
+ /** Model-facing description of `sidebar_browser`. */
795
+ const BROWSER_DESCRIPTION =
796
+ 'Manage the native browser tabs of the DSH right sidebar for the calling conversation. '
797
+ + '"open" shows an HTTP(S) page in a new sidebar browser tab; "list" returns open tabs with tab ids, URLs and titles; '
798
+ + '"navigate", "reload", "focus" and "close" act on one tab; "status" reports the page carrier and bridge health. '
799
+ + 'The Harness Web page must be open. "navigate" and "reload" drive the native guest and also work while another conversation is on screen; '
800
+ + '"open", "focus" and "close" change the visible sidebar layout and need the calling conversation to be the one on screen. '
801
+ + 'Use sidebar_page to read or interact with the loaded page, and web_fetch for page text without a tab.'
802
+
803
+ /** Model-facing description of `sidebar_page`. */
804
+ const PAGE_DESCRIPTION =
805
+ 'Read and operate the page loaded in a native DSH sidebar browser tab, on the real Chromium guest: '
806
+ + '"read" returns visible text, the list of interactive elements, or markup; '
807
+ + '"click" clicks an element by CSS selector, by visible text, or by viewport coordinates using native input; '
808
+ + '"type" writes into a field and optionally submits; "press" sends a native key; "scroll" moves the page or an element into view; '
809
+ + '"wait" polls for a selector or text; "eval" runs JavaScript in the page and returns its JSON result; '
810
+ + '"screenshot" captures the visible page and returns the image. '
811
+ + 'Works in the DSH Desktop app (native Electron browser). In a Web build the sidebar browser is a sandboxed cross-origin iframe, so page actions fail and only tab management plus web_fetch work. '
812
+ + 'A browser tab must be open in the calling conversation (sidebar_browser action "open" or "list"); page actions also work while another conversation is on screen, on the calling conversation\'s hidden sidebar tab. '
813
+ + 'Call "read" with mode "interactive" before clicking when you do not know the page structure.'
814
+
815
+ /**
816
+ * Human-readable projection of one `sidebar_browser` value.
817
+ * @param _args - model arguments (unused).
818
+ * @param value - canonical tool value.
819
+ * @returns model-facing content blocks.
820
+ */
821
+ function renderBrowser(_args, value) {
822
+ const result = value ?? {}
823
+ if (result.action === 'status') {
824
+ const head = result.pageConnected === true
825
+ ? 'Sidebar browser bridge: connected.'
826
+ : 'Sidebar browser bridge: not connected.'
827
+ return [{ type: 'text', text: result.note === undefined ? head : `${head} ${result.note}` }]
828
+ }
829
+ if (result.action === 'list') {
830
+ const tabs = Array.isArray(result.tabs) ? result.tabs : []
831
+ if (tabs.length === 0) return [{ type: 'text', text: 'No native sidebar browser tabs are open for this conversation.' }]
832
+ const lines = tabs.map((tab) => {
833
+ const bits = [`- ${tab.tabId}${tab.active === true ? ' (active)' : ''}`]
834
+ if (tab.title !== undefined) bits.push(`"${tab.title}"`)
835
+ if (tab.url !== undefined) bits.push(tab.url)
836
+ return bits.join(' ')
837
+ })
838
+ return [{ type: 'text', text: `Native sidebar browser tabs (${tabs.length}):\n${lines.join('\n')}` }]
839
+ }
840
+ const parts = [`sidebar_browser ${String(result.action)} completed.`]
841
+ if (result.tabId !== undefined) parts.push(`tab: ${result.tabId}`)
842
+ if (result.url !== undefined) parts.push(`url: ${result.url}`)
843
+ if (result.note !== undefined) parts.push(String(result.note))
844
+ return [{ type: 'text', text: parts.join(' ') }]
845
+ }
846
+
847
+ /**
848
+ * Format the interactive-element list of a read result.
849
+ * @param elements - element summaries.
850
+ * @returns one text block.
851
+ */
852
+ function renderElements(elements) {
853
+ const lines = elements.map((element) => {
854
+ const bits = [`[${element.index}] <${element.tag}${element.type === undefined ? '' : ` type=${element.type}`}>`]
855
+ if (element.label !== undefined) bits.push(`"${element.label}"`)
856
+ if (element.selector !== undefined) bits.push(`selector=${element.selector}`)
857
+ bits.push(`@(${element.x},${element.y})`)
858
+ if (element.href !== undefined) bits.push(element.href)
859
+ if (element.disabled === true) bits.push('[disabled]')
860
+ return bits.join(' ')
861
+ })
862
+ return [{ type: 'text', text: `Interactive elements (${elements.length}):\n${lines.join('\n')}` }]
863
+ }
864
+
865
+ /**
866
+ * Human-readable projection of one `sidebar_page` value, without its note.
867
+ * @param result - canonical tool value.
868
+ * @returns model-facing content blocks.
869
+ */
870
+ function renderPageBody(result) {
871
+ const where = [result.url === undefined ? '' : String(result.url), result.title === undefined ? '' : `"${String(result.title)}"`]
872
+ .filter((part) => part !== '')
873
+ .join(' ')
874
+ const header = where === '' ? '' : `${where}\n`
875
+
876
+ if (result.action === 'read') {
877
+ if (result.mode === 'interactive') {
878
+ const elements = Array.isArray(result.elements) ? result.elements : []
879
+ const info = `Page ${header}viewport ${result.viewport?.width ?? '?'}x${result.viewport?.height ?? '?'}, scrollY ${result.scroll?.y ?? '?'}/${result.scroll?.height ?? '?'}.\n`
880
+ return [{ type: 'text', text: info + renderElements(elements)[0].text }]
881
+ }
882
+ if (result.mode === 'html') {
883
+ const suffix = result.truncated === true ? `\n… truncated (${result.totalChars ?? '?'} chars total)` : ''
884
+ return [{ type: 'text', text: `${header}${String(result.html ?? '')}${suffix}` }]
885
+ }
886
+ const suffix = result.truncated === true ? `\n… truncated (${result.totalChars ?? '?'} chars total)` : ''
887
+ return [{ type: 'text', text: `${header}${String(result.text ?? '')}${suffix}` }]
888
+ }
889
+
890
+ if (result.action === 'screenshot') {
891
+ const image = result.image ?? {}
892
+ const attachment = {
893
+ attachmentId: String(image.attachmentId ?? ''),
894
+ mediaType: String(image.mediaType ?? 'image/png'),
895
+ bytes: Number(image.bytes ?? 0),
896
+ width: Number(image.width ?? result.width ?? 0),
897
+ height: Number(image.height ?? result.height ?? 0),
898
+ }
899
+ if (typeof image.name === 'string' && image.name !== '') attachment.name = image.name
900
+ return [
901
+ { type: 'text', text: `Sidebar browser screenshot of ${header || 'the page'} (${attachment.width}x${attachment.height} px).` },
902
+ { type: 'image', attachment },
903
+ ]
904
+ }
905
+
906
+ if (result.action === 'click') {
907
+ const target = result.target ?? {}
908
+ const label = target.label === undefined ? '' : ` "${target.label}"`
909
+ return [{ type: 'text', text: `Clicked <${target.tag ?? 'element'}>${label} at (${target.x ?? '?'},${target.y ?? '?'}) in ${header || 'the page'}` }]
910
+ }
911
+
912
+ if (result.action === 'type') {
913
+ const target = result.target ?? {}
914
+ return [{
915
+ type: 'text',
916
+ text: `Typed ${result.typed ?? 0} characters into <${target.tag ?? 'field'}>${result.submitted === true ? ' and pressed Enter' : ''} in ${header || 'the page'}`,
917
+ }]
918
+ }
919
+
920
+ if (result.action === 'press') {
921
+ return [{ type: 'text', text: `Pressed ${result.key ?? 'key'} in ${header || 'the page'}` }]
922
+ }
923
+
924
+ if (result.action === 'scroll') {
925
+ return [{
926
+ type: 'text',
927
+ text: `Scrolled ${header || 'the page'} to y=${result.scrollY ?? '?'} of ${result.scrollHeight ?? '?'} (viewport ${result.viewportHeight ?? '?'} px).`,
928
+ }]
929
+ }
930
+
931
+ if (result.action === 'wait') {
932
+ return [{
933
+ type: 'text',
934
+ text: result.matched === true
935
+ ? `Condition met after ${result.waitedMs ?? 0} ms in ${header || 'the page'}.`
936
+ : `Condition not met after ${result.waitedMs ?? 0} ms in ${header || 'the page'}.`,
937
+ }]
938
+ }
939
+
940
+ if (result.action === 'eval') {
941
+ const suffix = result.truncated === true ? '\n… result truncated' : ''
942
+ return [{ type: 'text', text: `JavaScript result for ${header || 'the page'}:\n${String(result.result ?? 'undefined')}${suffix}` }]
943
+ }
944
+
945
+ return [{ type: 'text', text: `sidebar_page ${String(result.action)} completed.` }]
946
+ }
947
+
948
+ /**
949
+ * Project one `sidebar_page` value and append its optional note.
950
+ * @param _args - model arguments (unused).
951
+ * @param value - canonical tool value.
952
+ * @returns model-facing content blocks.
953
+ */
954
+ function renderPage(_args, value) {
955
+ const result = value ?? {}
956
+ const blocks = renderPageBody(result)
957
+ if (typeof result.note === 'string' && result.note !== '') blocks.push({ type: 'text', text: result.note })
958
+ return blocks
959
+ }
960
+
961
+ // ---------------------------------------------------------------------------
962
+ // Tool execution helpers
963
+ // ---------------------------------------------------------------------------
964
+
965
+ /**
966
+ * Extract the calling session id or throw the canonical error.
967
+ * @param exec - tool execution context.
968
+ * @returns the session id.
969
+ */
970
+ function sessionIdOf(exec) {
971
+ const raw = exec.agent?.session?.id
972
+ if (typeof raw !== 'string' || raw === '') throw new Error('this tool requires an initiating agent session')
973
+ return raw
974
+ }
975
+
976
+ /**
977
+ * Throw the page half's failure as a tool error.
978
+ * @param result - bridge result.
979
+ * @param fallback - message used when the page half gave no reason.
980
+ * @returns never.
981
+ */
982
+ function throwPageFailure(result, fallback) {
983
+ throw new Error(result.error ?? fallback)
984
+ }
985
+
986
+ /**
987
+ * Register the `sidebar_browser` tool.
988
+ * @param ctx - host plugin context.
989
+ * @param bridge - running bridge.
990
+ * @returns a disposer unregistering the tool.
991
+ */
992
+ function registerBrowserTool(ctx, bridge, config) {
993
+ return ctx.tools.register({
994
+ name: 'sidebar_browser',
995
+ description: BROWSER_DESCRIPTION,
996
+ parameters: BROWSER_PARAMETERS,
997
+ timeoutMs: 60_000,
998
+ output: { schema: BROWSER_OUTPUT, render: renderBrowser },
999
+ async execute(rawArgs, exec) {
1000
+ exec.signal?.throwIfAborted?.()
1001
+ if (rawArgs === null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) throw new Error('sidebar_browser requires an object of arguments')
1002
+ const action = rawArgs.action
1003
+ if (typeof action !== 'string' || !TAB_ACTIONS.includes(action)) throw new Error(`sidebar_browser action must be one of: ${TAB_ACTIONS.join(', ')}`)
1004
+ const sessionId = typeof exec.agent?.session?.id === 'string' ? exec.agent.session.id : ''
1005
+
1006
+ if (action === 'status') {
1007
+ const command = { action, sessionId }
1008
+ if (!bridge.connected && Date.now() - bridge.lastPollAt > CLIENT_STALE_MS) {
1009
+ return {
1010
+ action: 'status',
1011
+ ...(sessionId === '' ? {} : { sessionId }),
1012
+ pageConnected: false,
1013
+ note: joinNotes([bridge.noClientMessage(), hostFacts(ctx, config)]),
1014
+ }
1015
+ }
1016
+ await bridge.ensureClient(exec.signal)
1017
+ const result = await bridge.dispatch(command, exec.signal)
1018
+ if (result.ok !== true) throwPageFailure(result, 'the page half did not answer')
1019
+ const value = result.value ?? {}
1020
+ const clientMatch = typeof value.note === 'string' ? /"plugin":"([^"]+)"/.exec(value.note) : null
1021
+ const clientVersion = clientMatch === null ? undefined : clientMatch[1]
1022
+ const versionNotice = clientVersion !== undefined && clientVersion !== PLUGIN_VERSION
1023
+ ? `warning: the page half is v${clientVersion} but the running Host half is v${PLUGIN_VERSION}; restart the Harness to load the installed Host code.`
1024
+ : ''
1025
+ return { ...value, note: joinNotes([typeof value.note === 'string' ? value.note : '', hostFacts(ctx, config), versionNotice]) }
1026
+ }
1027
+
1028
+ if (sessionId === '') throw new Error('sidebar_browser requires an initiating agent session')
1029
+ const command = { action, sessionId }
1030
+ if (URL_ACTIONS.has(action)) {
1031
+ command.url = normalizeHttpUrl(rawArgs.url, action)
1032
+ assertHostAllowed(command.url, liveConfig(config))
1033
+ }
1034
+ if (TAB_ID_ACTIONS.has(action)) {
1035
+ const tabId = requireString(rawArgs.tabId, 'tabId')
1036
+ if (tabId !== undefined) command.tabId = tabId
1037
+ }
1038
+
1039
+ await bridge.ensureClient(exec.signal)
1040
+ const result = await bridge.dispatch(command, exec.signal)
1041
+ if (result.ok !== true) throwPageFailure(result, 'the sidebar browser did not complete the command')
1042
+ return result.value
1043
+ },
1044
+ }, { when: () => liveConfig(config).enabled })
1045
+ }
1046
+
1047
+ /**
1048
+ * Register the `sidebar_page` tool.
1049
+ * @param ctx - host plugin context.
1050
+ * @param bridge - running bridge.
1051
+ * @returns a disposer unregistering the tool.
1052
+ */
1053
+ function registerPageTool(ctx, bridge, config) {
1054
+ return ctx.tools.register({
1055
+ name: 'sidebar_page',
1056
+ description: PAGE_DESCRIPTION,
1057
+ parameters: PAGE_PARAMETERS,
1058
+ timeoutMs: 90_000,
1059
+ output: { schema: PAGE_OUTPUT, render: renderPage },
1060
+ async execute(rawArgs, exec) {
1061
+ exec.signal?.throwIfAborted?.()
1062
+ if (rawArgs === null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) throw new Error('sidebar_page requires an object of arguments')
1063
+ const action = rawArgs.action
1064
+ if (typeof action !== 'string' || !PAGE_ACTIONS.includes(action)) throw new Error(`sidebar_page action must be one of: ${PAGE_ACTIONS.join(', ')}`)
1065
+ const current = liveConfig(config)
1066
+ if (action === 'eval' && current.allowEval !== true) throw new Error('sidebar_page action "eval" is disabled by the plugin configuration')
1067
+ if ((action === 'click' || action === 'type' || action === 'press') && current.allowNativeInput !== true) {
1068
+ throw new Error('sidebar_page click/type/press actions are disabled by the plugin configuration')
1069
+ }
1070
+ const sessionId = sessionIdOf(exec)
1071
+ const tabId = requireString(rawArgs.tabId, 'tabId', true)
1072
+ const selector = requireString(rawArgs.selector, 'selector', true)
1073
+ const contains = requireString(rawArgs.contains, 'contains', true)
1074
+ const command = {
1075
+ action,
1076
+ sessionId,
1077
+ ...(tabId === undefined ? {} : { tabId }),
1078
+ }
1079
+
1080
+ if (action === 'read') {
1081
+ const mode = rawArgs.mode === undefined ? 'text' : rawArgs.mode
1082
+ if (typeof mode !== 'string' || !READ_MODES.includes(mode)) throw new Error(`read mode must be one of: ${READ_MODES.join(', ')}`)
1083
+ command.mode = mode
1084
+ if (selector !== undefined) command.selector = selector
1085
+ if (mode === 'text' || mode === 'html') {
1086
+ command.maxChars = boundedNumber(rawArgs.maxChars, 'maxChars', 100, mode === 'html' ? 120_000 : 60_000, mode === 'html' ? 30_000 : 12_000)
1087
+ } else {
1088
+ command.maxElements = boundedNumber(rawArgs.maxElements, 'maxElements', 1, 500, 200)
1089
+ }
1090
+ } else if (action === 'click') {
1091
+ const hasPoint = typeof rawArgs.x === 'number' && typeof rawArgs.y === 'number'
1092
+ if (selector === undefined && contains === undefined && !hasPoint) throw new Error('click needs "selector", "contains", or numeric "x" and "y"')
1093
+ if (selector !== undefined) command.selector = selector
1094
+ if (contains !== undefined) command.contains = contains
1095
+ if (hasPoint) {
1096
+ command.x = boundedNumber(rawArgs.x, 'x', 0, 10_000, 0)
1097
+ command.y = boundedNumber(rawArgs.y, 'y', 0, 10_000, 0)
1098
+ }
1099
+ } else if (action === 'type') {
1100
+ if (rawArgs.text === undefined || typeof rawArgs.text !== 'string') throw new Error('type requires "text"')
1101
+ command.text = rawArgs.text
1102
+ if (selector !== undefined) command.selector = selector
1103
+ command.clear = rawArgs.clear !== false
1104
+ command.submit = rawArgs.submit === true
1105
+ } else if (action === 'press') {
1106
+ const key = requireString(rawArgs.key, 'key')
1107
+ if (key !== undefined) command.key = key
1108
+ } else if (action === 'scroll') {
1109
+ if (selector === undefined && typeof rawArgs.dy !== 'number' && typeof rawArgs.dx !== 'number') {
1110
+ throw new Error('scroll needs "selector", "dy", or "dx"')
1111
+ }
1112
+ if (selector !== undefined) command.selector = selector
1113
+ if (typeof rawArgs.dy === 'number') command.dy = boundedNumber(rawArgs.dy, 'dy', -100_000, 100_000, 0)
1114
+ if (typeof rawArgs.dx === 'number') command.dx = boundedNumber(rawArgs.dx, 'dx', -100_000, 100_000, 0)
1115
+ } else if (action === 'eval') {
1116
+ const code = requireString(rawArgs.code, 'code')
1117
+ if (code === undefined || code.length > 200_000) throw new Error('eval "code" must be 1..200000 characters')
1118
+ command.code = code
1119
+ } else if (action === 'wait') {
1120
+ if (selector === undefined && contains === undefined) throw new Error('wait needs "selector" or "contains"')
1121
+ if (selector !== undefined) command.selector = selector
1122
+ if (contains !== undefined) command.contains = contains
1123
+ command.timeoutMs = boundedNumber(rawArgs.timeoutMs, 'timeoutMs', 100, 15_000, 5_000)
1124
+ }
1125
+
1126
+ await bridge.ensureClient(exec.signal)
1127
+ const result = await bridge.dispatch(command, exec.signal)
1128
+ if (result.ok !== true) throwPageFailure(result, 'the sidebar page action failed')
1129
+
1130
+ if (action !== 'screenshot') return result.value
1131
+
1132
+ const value = result.value ?? {}
1133
+ const base64 = typeof value.base64 === 'string' ? value.base64 : ''
1134
+ if (base64 === '') throw new Error('the page half returned no screenshot payload')
1135
+ if (base64.length > MAX_SCREENSHOT_BASE64) throw new Error('the screenshot is too large to store; zoom or scroll and retry')
1136
+ const attachments = ctx.get('attachments')
1137
+ if (attachments === undefined) throw new Error('screenshots require the attachment service, which is not mounted in this profile')
1138
+ let ref
1139
+ try {
1140
+ ref = await attachments.saveImage({
1141
+ data: Buffer.from(base64, 'base64'),
1142
+ mediaType: 'image/png',
1143
+ name: `sidebar-browser-${value.tabId ?? 'tab'}.png`,
1144
+ })
1145
+ } catch (error) {
1146
+ throw new Error(`could not store the sidebar screenshot: ${error instanceof Error ? error.message : String(error)}`)
1147
+ }
1148
+ return {
1149
+ action: 'screenshot',
1150
+ sessionId: value.sessionId ?? sessionId,
1151
+ ...(value.tabId === undefined ? {} : { tabId: value.tabId }),
1152
+ ...(value.url === undefined ? {} : { url: value.url }),
1153
+ ...(value.title === undefined ? {} : { title: value.title }),
1154
+ width: ref.width,
1155
+ height: ref.height,
1156
+ image: {
1157
+ attachmentId: ref.attachmentId,
1158
+ mediaType: ref.mediaType,
1159
+ bytes: ref.bytes,
1160
+ width: ref.width,
1161
+ height: ref.height,
1162
+ ...(ref.name === undefined ? {} : { name: ref.name }),
1163
+ },
1164
+ }
1165
+ },
1166
+ }, { when: () => liveConfig(config).enabled })
1167
+ }
1168
+
1169
+ /**
1170
+ * Plugin body: mount the bridge routes, the configured tools, and teardown.
1171
+ * @param ctx - host plugin context.
1172
+ * @param config - validated plugin configuration.
1173
+ */
1174
+ export function apply(ctx, config) {
1175
+ const bridge = new Bridge(ctx)
1176
+ ctx.effect(() => bridge.registerRoutes(), 'sidebar-browser-agent: bridge routes')
1177
+ ctx.effect(() => registerBrowserTool(ctx, bridge, config), 'sidebar-browser-agent: browser tool')
1178
+ ctx.effect(() => registerPageTool(ctx, bridge, config), 'sidebar-browser-agent: page tool')
1179
+ ctx.effect(() => () => bridge.dispose(), 'sidebar-browser-agent: bridge lifetime')
1180
+ selfCheck(ctx, config)
1181
+ }