dsh-inner-browser-use 0.18.0 → 0.34.28

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/index.js CHANGED
@@ -20,8 +20,36 @@
20
20
  * learns no port and no secret. // ui-copy-ignore
21
21
  */
22
22
 
23
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { join } from 'node:path'
23
26
  import { Sidecar } from './sidecar.js?v=1'
24
27
 
28
+ /** Where the mirror's owner is remembered across a reload: the column should not go dark because the code reloaded. */
29
+ /** Where the mirror's note lives; a test points this elsewhere so it never reads a real chat. */
30
+ const STATE_DIR = process.env.DSH_INNER_BROWSER_STATE_DIR || join(homedir(), '.dsh', 'inner-browser-use')
31
+ const MIRROR_FILE = join(STATE_DIR, 'mirror.json')
32
+
33
+ /** @returns {{owner: string, session: string, at: number}} the last owner a chat asked to keep. */
34
+ function readMirror() {
35
+ try {
36
+ const saved = JSON.parse(readFileSync(MIRROR_FILE, 'utf8'))
37
+ return { owner: String(saved?.owner ?? ''), session: String(saved?.session ?? ''), at: Number(saved?.at ?? 0) }
38
+ } catch {
39
+ return { owner: '', session: '', at: 0 }
40
+ }
41
+ }
42
+
43
+ /** @param {{owner: string, session: string, at: number}} value - what to keep. */
44
+ function writeMirror(value) {
45
+ try {
46
+ mkdirSync(STATE_DIR, { recursive: true })
47
+ writeFileSync(MIRROR_FILE, JSON.stringify(value))
48
+ } catch {
49
+ /* an unwritable note must not take the mirror down */
50
+ }
51
+ }
52
+
25
53
  /** Route prefix owned by this plugin. Never under `/api`, which is the Host's own channel. */ // ui-copy-ignore
26
54
  export const ROUTE = '/dsh-inner-browser-use'
27
55
 
@@ -57,12 +85,25 @@ function readBody(req) {
57
85
  * Nothing else lives here; the browser itself is the Host's. // ui-copy-ignore
58
86
  */
59
87
  export class Bridge {
60
- constructor() {
88
+ /** @param {{persist?: boolean}} [options] - persist the owner across reloads. */
89
+ constructor(options = {}) {
61
90
  /** @type {Map<string, import('node:http').ServerResponse>} */
62
91
  this.seats = new Map()
63
92
  /* The one chat that owns the mirror. The column is global, so the picture
64
93
  may only ever be drawn for this chat; every other chat shows a note. */
65
94
  this.owner = ''
95
+ /* The tab asked for, kept until the chat comes back into view. A chat that
96
+ is not on screen has no stream, so a push at ask time is lost; remembering
97
+ the ask is what makes the mirror appear when the person returns. */
98
+ this.wanted = { session: '', at: 0 }
99
+ /* A running plugin keeps the note; a bare Bridge in a test does not. */
100
+ this.persist = options.persist === true
101
+ const kept = this.persist ? readMirror() : { owner: '', session: '', at: 0 }
102
+ /* Only a real chat id is restored: a stray note must not hand the mirror to
103
+ a chat that no longer exists. */
104
+ if (/^session-/.test(kept.owner) !== true) kept.owner = ''
105
+ this.owner = kept.owner
106
+ this.wanted = { session: kept.session, at: kept.at }
66
107
  }
67
108
 
68
109
  /**
@@ -70,7 +111,23 @@ export class Bridge {
70
111
  * @param {string} sessionId - chat that asked for it.
71
112
  */
72
113
  claim(sessionId) {
73
- if (sessionId !== '') this.owner = sessionId
114
+ if (sessionId === '') return
115
+ this.owner = sessionId
116
+ if (this.persist === true) writeMirror({ owner: this.owner, session: this.wanted.session, at: this.wanted.at })
117
+ }
118
+
119
+ /**
120
+ * Remember that a chat wants the mirror, and tell it now if it is listening.
121
+ * @param {string} sessionId - chat that asked.
122
+ * @param {number} [at] - when it asked; older asks are not replayed.
123
+ * @returns {boolean} whether a live tab took the frame.
124
+ */
125
+ want(sessionId, at = Date.now()) {
126
+ if (sessionId === '') return false
127
+ this.claim(sessionId)
128
+ this.wanted = { session: sessionId, at }
129
+ if (this.persist === true) writeMirror({ owner: this.owner, session: sessionId, at })
130
+ return this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at })
74
131
  }
75
132
 
76
133
  /**
@@ -81,6 +138,13 @@ export class Bridge {
81
138
  attach(sessionId, res) {
82
139
  this.seats.get(sessionId)?.end()
83
140
  this.seats.set(sessionId, res)
141
+ /* A tab that connects after the ask still gets the mirror: this is the case
142
+ where the person was looking at another chat, or reloaded the page. An
143
+ ask older than two minutes is spent and is not replayed. */
144
+ const ask = this.wanted
145
+ if (ask.session === sessionId && Date.now() - ask.at <= 120_000) {
146
+ this.push(sessionId, { type: 'sidecar-wanted', session: sessionId, at: ask.at })
147
+ }
84
148
  }
85
149
 
86
150
  /**
@@ -110,6 +174,17 @@ export class Bridge {
110
174
  return [...this.seats.keys()]
111
175
  }
112
176
 
177
+ /**
178
+ * Forget a mirror nobody is waiting for any more.
179
+ * @param {string} sessionId - chat that closed its tab.
180
+ * @returns {boolean} whether the ask was the remembered one.
181
+ */
182
+ forget(sessionId) {
183
+ if (sessionId === '' || this.wanted.session !== sessionId) return false
184
+ this.wanted = { session: '', at: 0 }
185
+ return true
186
+ }
187
+
113
188
  /** Close every stream. */
114
189
  dispose() {
115
190
  for (const res of this.seats.values()) res.end()
@@ -120,7 +195,62 @@ export class Bridge {
120
195
  /** Page read: address, title, controls, then text, as JSON the Host parses. */ // ui-copy-ignore
121
196
  /* The read model and the tool surface live in their own files: this module is
122
197
  the Host half (routes, bridge, lifetimes), not the model's vocabulary. */ // ui-copy-ignore
123
- import { sidecarTools } from './tools.js?v=1'
198
+ import { KEYS, sidecarTools, sessionScope, workScope } from './tools.js?v=1'
199
+
200
+ /**
201
+ * A state poll may pick up a browser that outlived a reload, and only for the
202
+ * chat that owns it: any chat polling would otherwise start a browser nobody
203
+ * asked for, and a chat with no column would look like it had one.
204
+ * @param {{alive?: boolean}} said - what the browser half answered.
205
+ * @param {string} chat - the chat asking.
206
+ * @param {string} owner - the chat that owns the mirror.
207
+ * @returns {boolean} whether this poll should attach to a running browser.
208
+ */
209
+ /**
210
+ * What the state poll hands the browser half when it picks a surviving browser
211
+ * up: the chat's own tab, at the address that chat last had. Without the chat
212
+ * the browser comes back with no tab for anybody, and the column stays empty.
213
+ * @param {string} chat - the chat that is polling.
214
+ * @param {string} remembered - where that chat left off, or ''.
215
+ * @returns {{session: string, url?: string}} options for `ensure`.
216
+ */
217
+ /**
218
+ * Whether this chat's poll should put its tab back: the browser is running, the
219
+ * chat holds the mirror, and its tab is not there.
220
+ * @param {any} chatState - what `chatState(chat)` said.
221
+ * @param {string} chat - the chat that is polling.
222
+ * @param {string} owner - the chat that holds the mirror.
223
+ * @returns {boolean} true when a tab has to be opened for this chat.
224
+ */
225
+ /**
226
+ * The folder the calling session works in, wherever it is kept.
227
+ * @param {any} execution - tool execution context.
228
+ * @returns {string} absolute path, or empty when nothing was said.
229
+ */
230
+ export function cwdOf(execution) {
231
+ try {
232
+ const agent = execution?.agent
233
+ const value = agent?.cwd ?? agent?.session?.header?.cwd ?? agent?.header?.cwd
234
+ if (typeof value === 'string' && value !== '') return value
235
+ } catch {
236
+ /* a context that cannot be read is a context without a folder */
237
+ }
238
+ return ''
239
+ }
240
+
241
+ export function needsTab(chatState, chat, owner) {
242
+ if (String(chat ?? '') === '' || owner !== chat) return false
243
+ return chatState?.alive !== true
244
+ }
245
+
246
+ export function adoptOptions(chat, remembered) {
247
+ const back = String(remembered ?? '')
248
+ return back === '' ? { session: chat } : { session: chat, url: back }
249
+ }
250
+
251
+ export function shouldAdoptOnState(said, chat, owner) {
252
+ return said?.alive === false && chat !== '' && owner === chat
253
+ }
124
254
 
125
255
  export const name = 'dsh-inner-browser-use'
126
256
  export const inject = ['webServer']
@@ -130,7 +260,7 @@ export const inject = ['webServer']
130
260
  * @param {any} ctx - plugin context.
131
261
  */
132
262
  export function apply(ctx) {
133
- const bridge = new Bridge()
263
+ const bridge = new Bridge({ persist: true })
134
264
  /* The sidecar is a browser the Host owns outright: a real Chrome with its own // ui-copy-ignore
135
265
  profile, shown in the column as a screencast. It outlives a plugin reload,
136
266
  and it does not need the chat to be on screen to do work. */
@@ -148,15 +278,21 @@ export function apply(ctx) {
148
278
  timer.unref?.()
149
279
  return () => {
150
280
  clearInterval(timer)
151
- void sidecar.shutdown().catch(() => {})
281
+ void sidecar.handoff().catch(() => {})
152
282
  }
153
283
  }, 'dsh-inner-browser-use: sidecar lifetime')
154
284
 
155
285
  /** @param {import('node:http').IncomingMessage} req - request. @param {import('node:http').ServerResponse} res - response. */
286
+ /** @type {{at: number, path: string, session: string}[]} the last few calls the column made. */
287
+ const recent = []
156
288
  const handler = async (req, res) => {
157
289
  const url = new URL(req.url ?? ROUTE, 'http://dsh.invalid')
158
290
  const path = url.pathname.slice(ROUTE.length) || '/'
159
291
  const sessionId = url.searchParams.get('session') ?? ''
292
+ /* Who came in and when: an empty picture has two very different causes, and
293
+ this is what tells a column that never connected from a page that is gone. */
294
+ recent.push({ at: Date.now(), path, session: sessionId.slice(-8) })
295
+ if (recent.length > 40) recent.splice(0, recent.length - 40)
160
296
 
161
297
  /** @param {number} code - status. @param {unknown} value - body. */
162
298
  const json = (code, value) => {
@@ -164,14 +300,40 @@ export function apply(ctx) {
164
300
  res.end(JSON.stringify(value))
165
301
  }
166
302
 
303
+ /* One answer to one question: may this chat act on the mirror? A browser
304
+ nobody holds is anybody's to take; once a chat holds it, another chat is
305
+ refused here instead of being trusted to check itself. */
306
+ /** @param {string} session - the chat a request speaks for. */
307
+ const held = (session) => session !== '' && bridge.owner !== '' && session !== bridge.owner
308
+ /** @returns {boolean} true when the request was refused. */
309
+ const refuse = () => {
310
+ json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner }) // ui-copy-ignore
311
+ return true
312
+ }
313
+ /* Every route that changes the page goes through one check: it must say
314
+ which chat it is for, and that chat must be the one holding the mirror.
315
+ Two rules in one place, so a new route cannot quietly skip one. */
316
+ /**
317
+ * @param {any} body - parsed request body, if any.
318
+ * @returns {string} the chat that may act, or '' when it was refused.
319
+ */
320
+ const acting = (body) => {
321
+ const who = String(body?.session ?? '') || sessionId
322
+ if (who === '') {
323
+ json(400, { ok: false, text: 'which chat asked for it' }) // ui-copy-ignore
324
+ return ''
325
+ }
326
+ if (held(who) && refuse()) return ''
327
+ return who
328
+ }
329
+
167
330
  if (path === '/sidecar/frames' && req.method === 'GET') {
168
331
  /* The column is global and only one chat may hold the mirror: a chat that
169
332
  does not own it is refused here, so a stale tab cannot stream a picture
170
333
  of somebody else's browser. */
171
- if (bridge.owner !== '' && sessionId !== bridge.owner) {
172
- json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
173
- return
174
- }
334
+ const asked = String(url.searchParams.get('session') ?? '')
335
+ const watching = asked !== '' ? asked : sessionId
336
+ if (held(watching) && refuse()) return
175
337
  res.writeHead(200, {
176
338
  'content-type': 'text/event-stream; charset=utf-8',
177
339
  'cache-control': 'no-cache, no-transform',
@@ -191,9 +353,13 @@ export function apply(ctx) {
191
353
  page wait: the newest picture is the only one worth sending. */
192
354
  if (res.writableLength > 3 * 1024 * 1024) return
193
355
  res.write(`data: ${JSON.stringify(frame)}\n\n`)
194
- })
356
+ }, watching)
195
357
  const info = await sidecar.state()
196
- res.write(`data: ${JSON.stringify({ type: 'hello', ...info })}\n\n`)
358
+ /* The pane needs one address to offer back when this chat's own page
359
+ went away; the state route answers the same question for the tab
360
+ header, so both carry it. */
361
+ const chat = watching === '' ? {} : { chat: await sidecar.chatState(watching) }
362
+ res.write(`data: ${JSON.stringify({ type: 'hello', ...info, ...chat })}\n\n`)
197
363
  } catch (error) {
198
364
  res.write(`data: ${JSON.stringify({ type: 'failed', text: String(error?.message ?? error) })}\n\n`)
199
365
  }
@@ -203,8 +369,64 @@ export function apply(ctx) {
203
369
  return
204
370
  }
205
371
 
372
+ if (path === '/sidecar/claim' && req.method === 'POST') {
373
+ /* The pane itself asks back what it was showing: after a reload the
374
+ forgot who owned the mirror, and the chat that still has the tab open is
375
+ the only one allowed to say so. A different owner is refused. */
376
+ let body
377
+ try {
378
+ body = JSON.parse(await readBody(req))
379
+ } catch {
380
+ json(400, { ok: false, text: 'bad frame' })
381
+ return
382
+ }
383
+ const id = String(body.session ?? '')
384
+ if (id === '') {
385
+ json(400, { ok: false, text: 'no chat' })
386
+ return
387
+ }
388
+ if (bridge.owner !== '' && bridge.owner !== id) {
389
+ json(409, { ok: false, owner: bridge.owner })
390
+ return
391
+ }
392
+ bridge.claim(id)
393
+ json(200, { ok: true, owner: bridge.owner })
394
+ return
395
+ }
396
+
206
397
  if (path === '/sidecar/state' && req.method === 'GET') {
207
- json(200, { ...(await sidecar.state()), owner: bridge.owner })
398
+ /* Which chats have a column open right now: 0 means nobody is watching,
399
+ so an empty picture is the column's own problem, not the browser's. */
400
+ const chat = String(url.searchParams.get('session') ?? '')
401
+ /* Diagnostics belong to the chat that made them: a caller sees its own
402
+ calls, and one chat's browsing never explains another's. */
403
+ const mine = chat === '' ? recent : recent.filter((entry) => entry.session === chat.slice(-8))
404
+ let said = await sidecar.state()
405
+ /* The browser can be running and still have no tab for this chat: a reload
406
+ replaces the half that remembered which tab belonged to whom. The poll
407
+ is this chat asking, so it answers by putting that chat's tab back
408
+ before the column draws an empty picture — waiting for a claim first
409
+ left a blank frame in front of the person. */
410
+ let mineChat = chat === '' ? undefined : await sidecar.chatState(chat)
411
+ if (needsTab(mineChat, chat, bridge.owner)) {
412
+ try {
413
+ await sidecar.ensure(adoptOptions(chat, sidecar.rememberedTab?.(chat)))
414
+ said = await sidecar.state()
415
+ mineChat = await sidecar.chatState(chat)
416
+ } catch {}
417
+ }
418
+ /* A plugin reload leaves the browser running and this half brand new, so
419
+ the column's first poll would answer "nothing open" until somebody
420
+ called open again: the mirror would come back empty in a chat that
421
+ never closed it. The chat that owned the browser asks for its state, so
422
+ answering honestly means picking the browser up first. */
423
+ if (shouldAdoptOnState(said, chat, bridge.owner)) {
424
+ try {
425
+ await sidecar.ensure(adoptOptions(chat, sidecar.rememberedTab?.(chat)))
426
+ said = await sidecar.state()
427
+ } catch {}
428
+ }
429
+ json(200, { ...said, ...(chat === '' ? {} : { chat: mineChat ?? (await sidecar.chatState(chat)) }), owner: bridge.owner, chats: [...bridge.seats.keys()], recent: mine })
208
430
  return
209
431
  }
210
432
 
@@ -219,19 +441,21 @@ export function apply(ctx) {
219
441
  const address = String(body.url ?? '')
220
442
  /* The ask must name the chat it belongs to: a request that cannot say
221
443
  who it is for never gets the mirror. */
222
- const who = String(body.session ?? '')
444
+ /* An open names its chat and then takes the mirror over: that is what
445
+ asking for it means, so another owner is not a refusal here. */
446
+ const who = String(body.session ?? '') || sessionId
223
447
  if (who === '') {
224
- json(400, { ok: false, text: 'which chat asked for it' })
448
+ json(400, { ok: false, text: 'which chat asked for it' }) // ui-copy-ignore
225
449
  return
226
450
  }
227
451
  await sidecar.ensure({
228
452
  url: address === '' ? undefined : address,
229
453
  headless: body.headless === undefined ? undefined : body.headless === true,
454
+ session: who,
230
455
  })
231
456
  /* The mirror belongs to the chat that asked for it; any earlier owner
232
457
  loses it and its tab. */
233
- bridge.claim(who)
234
- bridge.push(who, { type: 'sidecar-wanted', session: who })
458
+ bridge.want(who)
235
459
  json(200, { ok: true, ...(await sidecar.state()), owner: bridge.owner })
236
460
  } catch (error) {
237
461
  json(500, { ok: false, text: String(error?.message ?? error) })
@@ -249,12 +473,10 @@ export function apply(ctx) {
249
473
  }
250
474
  /* Input follows the owner: a chat that lost the mirror cannot act on the
251
475
  page from a stale tab either. */
252
- if (body.session !== undefined && String(body.session) !== '' && bridge.owner !== '' && String(body.session) !== bridge.owner) {
253
- json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
254
- return
255
- }
476
+ const actor = acting(body)
477
+ if (actor === '') return
256
478
  try {
257
- const page = await sidecar.ensure()
479
+ const page = await sidecar.ensure({ session: actor })
258
480
  const kind = String(body.kind ?? '')
259
481
  const x = Number(body.x ?? 0)
260
482
  const y = Number(body.y ?? 0)
@@ -304,12 +526,10 @@ export function apply(ctx) {
304
526
  return
305
527
  }
306
528
  /* Navigation follows the owner too: a lost tab cannot steer the page. */
307
- if (body.session !== undefined && String(body.session) !== '' && bridge.owner !== '' && String(body.session) !== bridge.owner) {
308
- json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
309
- return
310
- }
529
+ const actor = acting(body)
530
+ if (actor === '') return
311
531
  try {
312
- const page = await sidecar.ensure()
532
+ const page = await sidecar.ensure({ session: actor })
313
533
  const op = String(body.op ?? 'goto')
314
534
  if (op === 'reload') json(200, { ok: true, ...(await page.reload()) })
315
535
  else if (op === 'back') json(200, { ok: true, ...(await page.history(-1)) })
@@ -325,7 +545,22 @@ export function apply(ctx) {
325
545
  return
326
546
  }
327
547
 
548
+ if (path === '/sidecar/forget' && req.method === 'POST') {
549
+ /* Closing the tab is an answer: coming back should not reopen it. Only the
550
+ chat that holds the mirror may withdraw its own ask. */
551
+ const body = await readBody(req).then((raw) => JSON.parse(raw || '{}')).catch(() => ({}))
552
+ const who = acting(body)
553
+ if (who === '') return
554
+ json(200, { ok: true, forgot: bridge.forget(who) })
555
+ return
556
+ }
557
+
328
558
  if (path === '/sidecar/stop' && req.method === 'POST') {
559
+ /* Closing the browser is a write as well: a chat that does not hold the
560
+ mirror cannot close another chat's page. */
561
+ const body = await readBody(req).then((raw) => JSON.parse(raw || '{}')).catch(() => ({}))
562
+ const who = String(body.session ?? '') || sessionId
563
+ if (who !== '' && held(who) && refuse()) return
329
564
  await sidecar.shutdown()
330
565
  json(200, { ok: true })
331
566
  return
@@ -359,13 +594,69 @@ export function apply(ctx) {
359
594
 
360
595
  ctx.inject(['tools'], (scope) => {
361
596
  const disposers = sidecarTools(sidecar, sidecarRefs, {
597
+ /* A screenshot the model can look at is the difference between planning
598
+ from the picture and guessing from a path. The store and the route that
599
+ can carry it are Harness services, so the tool asks here for it; when
600
+ either is missing the picture stays a file and the call still works. */
601
+ /**
602
+ * @param {string} data - base64 PNG.
603
+ * @param {string} name - file name for the attachment.
604
+ * @param {any} exec - the tool call's context.
605
+ * @returns {Promise<any>} the attachment value, or undefined to stay text-only.
606
+ */
607
+ async showShot(data, name, exec) {
608
+ try {
609
+ const attachments = ctx.get('attachments')
610
+ const llm = ctx.get('llm')
611
+ if (attachments === undefined || llm === undefined) return undefined
612
+ const route = exec?.agent?.session?.requestHeader?.()?.config
613
+ const provider = route?.provider ?? exec?.agent?.options?.provider
614
+ const model = route?.model ?? exec?.agent?.options?.model
615
+ if (provider === undefined || model === undefined) return undefined
616
+ const info = await llm.resolveModelInfo(provider, model, exec?.signal)
617
+ if (!Array.isArray(info?.inputModalities) || !info.inputModalities.includes('image')) return undefined
618
+ const bytes = Buffer.from(data, 'base64')
619
+ const cap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes)
620
+ if (!(bytes.length > 0) || bytes.length > cap) return undefined
621
+ const ref = await attachments.saveImage({ data: bytes, mediaType: 'image/png', name })
622
+ return {
623
+ attachmentId: ref.attachmentId, // ui-copy-ignore
624
+ mediaType: ref.mediaType, // ui-copy-ignore
625
+ bytes: ref.bytes, // ui-copy-ignore
626
+ width: ref.width, // ui-copy-ignore
627
+ height: ref.height, // ui-copy-ignore
628
+ ...(ref.name === undefined ? {} : { name: ref.name }),
629
+ }
630
+ } catch {
631
+ return undefined
632
+ }
633
+ },
362
634
  /* The mirror changes hands here: the calling chat owns it from now on,
363
635
  and every earlier owner loses it. */
364
636
  want: (sessionId) => {
365
- bridge.claim(sessionId)
366
- bridge.push(sessionId, { type: 'sidecar-wanted', session: sessionId })
637
+ bridge.want(sessionId)
367
638
  },
368
- }).map((definition) => scope.tools.register(definition))
639
+ /* Who holds the mirror, and whether a chat is listening right now. */
640
+ owner: () => bridge.owner, // ui-copy-ignore
641
+ seats: () => [...bridge.seats.keys()].map((k) => String(k).slice(-8)).join(',') || 'none', // ui-copy-ignore
642
+ }).map((definition) => {
643
+ /* A reload can mount this surface twice before the old one is torn down.
644
+ A name already in the registry is kept, not fought over: a duplicate
645
+ registration is a warning, never a crash. */
646
+ try {
647
+ const run = definition.execute
648
+ const scoped = {
649
+ ...definition,
650
+ /* Every tool call runs inside the chat that made it, so the browser
651
+ hands that chat its own tab and no other. */
652
+ execute: async (args, exec) => sessionScope.run(String(exec?.agent?.id ?? ''), () => workScope.run(cwdOf(exec), () => run(args, exec))),
653
+ }
654
+ return scope.tools.register(scoped)
655
+ } catch (error) {
656
+ ctx.logger?.warn?.(`browser tool ${definition.name} already registered: ${String(error?.message ?? error)}`)
657
+ return () => {}
658
+ }
659
+ })
369
660
  scope.effect(() => () => {
370
661
  for (const dispose of disposers) dispose()
371
662
  }, 'dsh-inner-browser-use: tool surface')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.18.0",
3
+ "version": "0.34.28",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,6 +16,7 @@
16
16
  "sidecar.js",
17
17
  "tools.js",
18
18
  "read.js",
19
+ "review.js",
19
20
  "client.js",
20
21
  "locale/*.json",
21
22
  "cordis.patch.yml",
@@ -45,10 +46,15 @@
45
46
  "test": "node --test test/*.test.mjs",
46
47
  "doctor": "node scripts/doctor.mjs",
47
48
  "breadth": "node scripts/breadth.mjs",
48
- "live": "node scripts/live.mjs"
49
+ "live": "node scripts/live.mjs",
50
+ "adopt": "node scripts/check-adopt.mjs",
51
+ "game": "node scripts/check-game.mjs"
49
52
  },
50
53
  "license": "MIT",
51
- "author": "regrevia",
54
+ "author": {
55
+ "name": "regrevia",
56
+ "url": "https://github.com/regrevia"
57
+ },
52
58
  "repository": {
53
59
  "type": "git",
54
60
  "url": "git+https://github.com/regrevia/dsh-inner-browser-use.git"