dsh-inner-browser-use 0.11.44 → 0.13.0

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
@@ -1,127 +1,32 @@
1
1
  /**
2
2
  * dsh-inner-browser-use — host half. // ui-copy-ignore
3
3
  *
4
- * The sidebar browser lives in the Client: a `<webview>` leased from the
5
- * Desktop main process, one partition per workspace. A tool runs on the Host, // ui-copy-ignore
6
- * so the Host needs a way to reach that guest. This file owns that wire, and // ui-copy-ignore
7
- * the tool surface the model sees.
4
+ * The browser this plugin drives is its own Chrome: a real process, started // ui-copy-ignore
5
+ * with a profile directory outside the Host, so logins outlive a plugin // ui-copy-ignore
6
+ * reload and a Host restart. The Host owns it and drives it over the DevTools // ui-copy-ignore
7
+ * protocol, so a tool works whether or not a chat is on screen. // ui-copy-ignore
8
8
  *
9
- * Transport is same-origin HTTP through the product document:
9
+ * The Client never touches that Chrome. It opens one server-sent event stream
10
+ * of JPEG frames and posts input back:
10
11
  *
11
- * - the Client `POST`s frames to `/dsh-inner-browser-use/uplink`;
12
- * - the Host pushes frames down `/dsh-inner-browser-use/downlink` as // ui-copy-ignore
13
- * server-sent events on one long-lived response;
14
- * - a tool call on the Host waiting for a round trip resolves when its reply // ui-copy-ignore
15
- * frame arrives on the uplink.
12
+ * - `GET /sidecar/frames` — the mirror, plus pointer messages; // ui-copy-ignore
13
+ * - `GET /sidecar/state` — what the browser is; // ui-copy-ignore
14
+ * - `POST /sidecar/open` — start or reuse it, and ask for the tab; // ui-copy-ignore
15
+ * - `POST /sidecar/input` — mouse, wheel, text and keys; // ui-copy-ignore
16
+ * - `POST /sidecar/nav` — address, back, forward, reload; // ui-copy-ignore
17
+ * - `POST /sidecar/stop` — close the browser and keep the profile. // ui-copy-ignore
16
18
  *
17
- * Same-origin is what makes this work on both faces of the product. In the Web
18
- * build the page already sits on the Host origin. In the Desktop build the // ui-copy-ignore
19
- * product document is `dsh-app://app`, whose protocol handler forwards any path
20
- * it does not serve itself to the Host with the Host cookie attached — so a // ui-copy-ignore
21
- * relative `fetch` from the page reaches these routes without CORS and without
22
- * the Client learning a port or a token. // ui-copy-ignore
23
- *
24
- * One transport, two faces, no port, no secret: that is the whole design. // ui-copy-ignore
25
- */
26
-
27
- import { createWriteStream, readFileSync, statSync } from 'node:fs'
28
- import { createHash, randomUUID } from 'node:crypto'
29
- import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
30
- import { homedir } from 'node:os'
31
- import { basename, dirname, join, resolve } from 'node:path'
32
- import { fileURLToPath } from 'node:url'
33
-
34
- /**
35
- * Everything this plugin writes lands under this one folder of the chat's working
36
- * directory, so a project's root gains nothing but a single hidden name.
19
+ * Every route is same-origin through the product document, so the Client
20
+ * learns no port and no secret. // ui-copy-ignore
37
21
  */
38
- const WORK_DIR = '.dsh-inner-browser-use'
39
22
 
40
- /** Screenshots of the page. */
41
- const SHOT_DIR = join(WORK_DIR, 'screenshots')
42
-
43
- /** Files the page offers. */
44
- const DOWNLOAD_DIR = join(WORK_DIR, 'downloads')
45
-
46
- /** Makes that folder keep itself out of the project's own version control. */
47
- const KEEP = '# written by the chat while it reads pages\n*\n'
48
-
49
- /**
50
- * Sign-in cookies wait here between app runs: one file per workspace key, in the
51
- * user's profile rather than the project, readable by that user only.
52
- * @returns {string} folder that holds them.
53
- */
54
- function cookieDir() {
55
- return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'inner-browser-use', 'cookies')
56
- }
57
-
58
- /** What the guest may keep: more than this is a page resetting itself, not a sign-in. */
59
- const MAX_COOKIES = 600
60
- const MAX_COOKIE_BYTES = 512 * 1024
61
-
62
- /** Largest file the plugin will push into a page. */
63
- const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
64
-
65
- /** Types the guest needs for the common cases; the page rarely checks. */
66
- const TYPES = {
67
- '.png': 'image/png',
68
- '.jpg': 'image/jpeg',
69
- '.jpeg': 'image/jpeg',
70
- '.webp': 'image/webp',
71
- '.gif': 'image/gif',
72
- '.pdf': 'application/pdf',
73
- '.txt': 'text/plain',
74
- '.json': 'application/json',
75
- '.csv': 'text/csv',
76
- '.zip': 'application/zip',
77
- }
78
-
79
- /** @param {string} file - absolute path. @returns {string} media type for the guest. */
80
- function mediaType(file) {
81
- const dot = file.lastIndexOf('.')
82
- return TYPES[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
83
- }
23
+ import { Sidecar } from './sidecar.js?v=1'
84
24
 
85
25
  /** Route prefix owned by this plugin. Never under `/api`, which is the Host's own channel. */ // ui-copy-ignore
86
26
  export const ROUTE = '/dsh-inner-browser-use'
87
27
 
88
- /** Largest uplink frame accepted. Frames are DOM observations, not screenshots. */
89
- const MAX_FRAME_BYTES = 4 * 1024 * 1024
90
-
91
- /** How long one page operation may take before the Host gives up on it. */ // ui-copy-ignore
92
- const DEFAULT_TIMEOUT_MS = 25_000
93
-
94
- /** Navigation waits on the page itself, which is slower than reading it. */
95
- const NAVIGATE_TIMEOUT_MS = 60_000
96
-
97
- /** A file crosses the page, the bridge and the disk inside this budget. */
98
- const DOWNLOAD_TIMEOUT_MS = 120_000
99
-
100
- /** How long a tool waits for the tab it asked for. */
101
- const OPEN_WAIT_MS = 20_000
102
-
103
- /** Shown when a tool runs without the tab that owns the guest. */
104
- const EVENT_KEEP = 300
105
- /** Said when a tool run does not name the chat it belongs to. */
106
- const NO_CHAT = 'this call has no chat identity, so no page is touched'
107
-
108
- /** @param {any} exec - tool run context. @returns {string} the chat id, never empty. */
109
- function chatOf(exec) {
110
- const chat = String(exec?.agent?.id ?? '')
111
- if (chat === '') throw new Error(NO_CHAT)
112
- return chat
113
- }
114
- const ASK_GAP_MS = 3_000
115
- const CLIENT_TTL_MS = 60_000
116
- const NO_TAB = 'The built-in browser is not open for this chat yet. The next call opens it in the right sidebar.'
117
- const ELSEWHERE = 'This chat is not the one on screen. Open it, then call again.'
118
- const NO_PANEL = 'The browser panel could not be taken here. Open the 内置浏览器 tab in the right sidebar, then call again.'
119
- const PAGE_LOST = 'The page this chat was using is gone: the browser released it or replaced it, so nothing here can carry on in the same state. Call browser_navigate to open the address again as a new page, and read the state afresh.'
120
- const LEGACY_PAGE = 'The window is still running the previous copy of this plugin. Reload the page once, then call again.'
121
- const NOT_DRAWN = "This chat's page is not the one being read. Open this chat, then call again; browser_script reads the page as it is."
122
-
123
- /** @type {string} */
124
- let proofLog = process.env.DSH_INNER_BROWSER_PROOF ?? '/tmp/dsh-inner-browser-use.log'
28
+ /** Largest request body accepted. */
29
+ const MAX_BODY_BYTES = 1024 * 1024
125
30
 
126
31
  /**
127
32
  * Read a request body with a hard ceiling.
@@ -130,12 +35,13 @@ let proofLog = process.env.DSH_INNER_BROWSER_PROOF ?? '/tmp/dsh-inner-browser-us
130
35
  */
131
36
  function readBody(req) {
132
37
  return new Promise((resolve, reject) => {
38
+ /** @type {Buffer[]} */
133
39
  const chunks = []
134
40
  let size = 0
135
41
  req.on('data', (chunk) => {
136
42
  size += chunk.length
137
- if (size > MAX_FRAME_BYTES) {
138
- reject(new Error('frame too large'))
43
+ if (size > MAX_BODY_BYTES) {
44
+ reject(new Error('body too large'))
139
45
  req.destroy()
140
46
  return
141
47
  }
@@ -147,807 +53,104 @@ function readBody(req) {
147
53
  }
148
54
 
149
55
  /**
150
- * One Client connection is one seat: the tab that currently holds the guest.
151
- * Tools route to the seat of the session that asked, and fall back to the only
152
- * seat when exactly one tab is open.
153
- */
154
- /**
155
- * The file a workspace's cookies live in. The key is a workspace name, so the user
156
- * can delete one file and sign in again for that workspace only.
157
- * @param {string} key - workspace key the guest was leased with.
158
- * @returns {string} absolute path of its cookie file.
159
- */
160
- /**
161
- * The folder a chat's files land under: its project, else the folder the run
162
- * works in, else the folder the person's account calls home, so a chat that
163
- * named no project still leaves a file where it can be found.
164
- * @param {any} bridge - the bridge, for the folders a chat already named.
165
- * @param {string} chat - chat asking.
166
- * @param {string} [agentCwd] - folder the run works in, when it has one.
167
- * @returns {string} absolute folder.
168
- */
169
- export function workBase(bridge, chat, agentCwd) {
170
- const known = bridge?.cwdOf?.(chat)
171
- if (String(known ?? '') !== '') return String(known)
172
- if (String(agentCwd ?? '') !== '') return String(agentCwd)
173
- const named = bridge?.folderOf?.(chat)
174
- if (String(named ?? '') !== '') return String(named)
175
- const home = homedir()
176
- return String(home ?? '') === '' ? process.cwd() : home
177
- }
178
-
179
- export function cookiePath(key) {
180
- const digest = createHash('sha256').update(String(key)).digest('hex').slice(0, 24)
181
- return join(cookieDir(), `${digest}.json`)
182
- }
183
-
184
- /**
185
- * Keep only what a cookie is: a name, a value, and where it belongs. Anything else
186
- * a page reports is dropped rather than handed back to the guest.
187
- * @param {unknown} list - what the page sent.
188
- * @returns {Record<string, unknown>[]} cookie fields worth keeping.
189
- */
190
- export function keptCookies(list) {
191
- if (!Array.isArray(list)) return []
192
- const out = []
193
- for (const each of list) {
194
- if (each === null || typeof each !== 'object') continue
195
- const one = /** @type {Record<string, unknown>} */ (each)
196
- if (typeof one.name !== 'string' || typeof one.value !== 'string') continue
197
- if (typeof one.domain !== 'string' && typeof one.url !== 'string') continue
198
- out.push(one)
199
- if (out.length >= MAX_COOKIES) break
200
- }
201
- return out
202
- }
203
-
204
- /**
205
- * Read the cookies kept for a workspace.
206
- * @param {string} key - workspace key.
207
- * @returns {Promise<Record<string, unknown>[]>} cookies, empty when none were kept.
208
- */
209
- export async function readCookies(key) {
210
- try {
211
- const text = await readFile(cookiePath(key), 'utf8')
212
- if (text.length > MAX_COOKIE_BYTES) return []
213
- return keptCookies(JSON.parse(text))
214
- } catch {
215
- return []
216
- }
217
- }
218
-
219
- /**
220
- * Replace the cookies kept for a workspace.
221
- * @param {string} key - workspace key.
222
- * @param {unknown} list - cookies the page reported.
223
- * @returns {Promise<number>} how many were kept.
224
- */
225
- export async function writeCookies(key, list) {
226
- const kept = keptCookies(list)
227
- await mkdir(cookieDir(), { recursive: true })
228
- const file = cookiePath(key)
229
- if (kept.length === 0) {
230
- await writeFile(file, '[]', { mode: 0o600 })
231
- return 0
232
- }
233
- const text = JSON.stringify(kept)
234
- if (text.length > MAX_COOKIE_BYTES) return 0
235
- await writeFile(file, text, { mode: 0o600 })
236
- await chmod(file, 0o600).catch(() => {})
237
- return kept.length
238
- }
239
-
240
- /**
241
- * One heartbeat as the state route reports it: the page half is fresh while it
242
- * answered within `CLIENT_TTL_MS`, and a stale beat is kept, not erased — it is
243
- * the only clue that a page loaded earlier and then stopped.
244
- * @param {{ms: number, frame: Record<string, unknown>} | undefined} beat - last heartbeat.
245
- * @param {number} [now] - current time in ms.
246
- * @returns {Record<string, unknown> | null} - the reported client, or null before any beat.
56
+ * One Client connection per chat: the tab that receives pushes for it.
57
+ * Nothing else lives here; the browser itself is the Host's. // ui-copy-ignore
247
58
  */
248
- export function clientState(beat, now = Date.now()) {
249
- if (beat === undefined) return null
250
- return { ...beat.frame, at: new Date(beat.ms).toISOString(), fresh: now - beat.ms < CLIENT_TTL_MS }
251
- }
252
-
253
59
  export class Bridge {
254
60
  constructor() {
255
- /** @type {Map<string, {res: import('node:http').ServerResponse, sessionId: string}>} */
61
+ /** @type {Map<string, import('node:http').ServerResponse>} */
256
62
  this.seats = new Map()
257
- /** @type {Map<string, {resolve: (v: unknown) => void, reject: (e: Error) => void, timer: NodeJS.Timeout}>} */
258
- this.waiters = new Map()
259
- /** @type {Map<string, string>} */
260
- this.refused = new Map()
261
- /** @type {{at: string, frame: unknown} | undefined} */
262
- this.client = undefined
263
- /** The last few beats, so a page that fell over keeps what it said on the way.
264
- * @type {Array<{at: string, frame: any}>} */
265
- this.trail = []
266
- /** The decisions this bridge made, newest last: which chat asked, which tab
267
- * answered, who was refused. A wrong page is traced from here, not guessed.
268
- * @type {Array<{at: string, what: string, chat?: string, reason?: string}>} */
269
- this.events = []
270
- /** Whether the page said it drives the product's own browser; undefined until it says. @type {boolean | undefined} */
271
- this.native = undefined
272
- /** @type {Set<import('node:http').ServerResponse>} open watch streams: the page's listener that can open tabs. */
273
- this.watchers = new Set()
274
- /** When each chat last asked for a tab, so one ask per call burst. @type {Map<string, number>} */
275
- this.asked = new Map()
276
- /** Named in every heartbeat reply: a page whose stream predates this bridge
277
- * still looks connected and reaches nothing, so the reply tells it to reopen. */
278
- this.epoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
279
- /** @type {{chat: string, at: number} | undefined} the ask still waiting for a tab. */
280
- this.asking = undefined
281
- /** @type {number} how long a call waits for the tab it asked for. */
282
- this.openWaitMs = OPEN_WAIT_MS
283
- }
284
-
285
- /**
286
- * Take one watch stream: the Client half that can open a tab on request.
287
- * @param {import('node:http').ServerResponse} res - open SSE response.
288
- */
289
- watch(res) {
290
- this.watchers.add(res)
291
- }
292
-
293
- /**
294
- * Learn which tab answers this chat: the page reports the id it opened right
295
- * after the ask, and that id is what the tab's own stream arrives under.
296
- * @param {Record<string, unknown>} frame - one heartbeat.
297
- */
298
- /**
299
- * A page that will not answer this chat says why, so the call need not wait.
300
- * @param {{phase?: string, step?: string, session?: string}} frame - heartbeat from the page.
301
- * @returns {void}
302
- */
303
- note(what, detail = {}) {
304
- this.events.push({ at: new Date().toISOString(), what, ...detail })
305
- if (this.events.length > EVENT_KEEP) this.events.splice(0, this.events.length - EVENT_KEEP)
306
- }
307
-
308
- noteStep(frame) {
309
- if (frame?.phase !== 'open' || typeof frame.session !== 'string' || frame.session === '') return
310
- if (frame.step === 'other-chat') this.refused.set(frame.session, ELSEWHERE)
311
- if (frame.step === 'rightbar') this.refused.set(frame.session, NO_PANEL)
312
- if (frame.step === 'page-lost') this.refused.set(frame.session, PAGE_LOST)
313
- /* A page that opened answers with the tab it opened; only a refusal is news. */
314
- if (frame.step === undefined) return
315
- this.note('refused', { chat: frame.session, reason: String(frame.step) })
316
- }
317
-
318
- noteOpen(frame) {
319
- const asking = this.asking
320
- if (asking === undefined) return
321
- /* Only an answer that says what it opened is an answer; the page also
322
- reports tab housekeeping under the same phase, and that is not a reply. */
323
- const opened = frame?.opened
324
- if (opened === undefined || opened === null || opened === '') return
325
- if (opened !== 'current' && opened !== 'session') {
326
- this.note('open-ignored', { chat: asking.chat, reason: String(opened) })
327
- return
328
- }
329
- /* The answer names the chat it opened for, and only that chat may take it:
330
- pairing by arrival time is how one chat's call was once answered with
331
- another chat's tab. */
332
- if (String(frame?.session ?? '') !== asking.chat) {
333
- this.note('open-rejected', { chat: asking.chat, reason: `answer named ${String(frame?.session ?? '')}` })
334
- return
335
- }
336
- const req = String(frame?.req ?? '')
337
- if (req !== '' && asking.req !== '' && req !== asking.req) {
338
- this.note('open-rejected', { chat: asking.chat, reason: 'answer for another ask' })
339
- return
340
- }
341
- /* The page that opened it attaches its own stream; the call waits for that
342
- seat rather than one being invented from the answer. */
343
- this.asking = undefined
344
- this.note('open-answered', { chat: asking.chat })
345
- }
346
-
347
- /** @param {import('node:http').ServerResponse} res - closed SSE response. */
348
- unwatch(res) {
349
- this.watchers.delete(res)
63
+ /* The one chat that owns the mirror. The column is global, so the picture
64
+ may only ever be drawn for this chat; every other chat shows a note. */
65
+ this.owner = ''
350
66
  }
351
67
 
352
68
  /**
353
- * Ask every listening page to open this chat's tab.
354
- * @param {string} sessionId - chat that needs a tab.
69
+ * Give the mirror to one chat and take it away from every earlier one.
70
+ * @param {string} sessionId - chat that asked for it.
355
71
  */
356
- want(sessionId, page = '') {
357
- const now = Date.now()
358
- const last = this.asked.get(sessionId)
359
- if (last !== undefined && now - last < ASK_GAP_MS) return
360
- if (this.watchers.size === 0) return
361
- this.asked.set(sessionId, now)
362
- this.note('asked', { chat: sessionId, reason: page === '' ? 'no address' : page.slice(0, 120) })
363
- const req = `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
364
- this.asking = { chat: sessionId, at: now, page, req }
365
- const frame = `data: ${JSON.stringify({ type: 'wanted', session: sessionId, url: page, req })}\n\n`
366
- for (const res of this.watchers) res.write(frame)
367
- }
368
-
369
- /** @param {string} sessionId - chat asking. @returns {object | undefined} the tab that can take it. */
370
- seatFor(sessionId) {
371
- /* A tab belongs to the chat that opened it. Handing one chat's call to
372
- another chat's tab steers a page the person is reading, so a chat with no
373
- tab of its own waits for its own instead of borrowing one. */
374
- return this.seats.get(sessionId)
375
- }
376
-
377
- /** @param {string} sessionId - chat asking. @returns {boolean} whether a tab can take the call now. */
378
- hasSeat(sessionId) {
379
- return this.seatFor(sessionId) !== undefined
72
+ claim(sessionId) {
73
+ if (sessionId !== '') this.owner = sessionId
380
74
  }
381
75
 
382
76
  /**
383
- * Wait for a tab to appear after it was asked for.
384
- * @param {string} sessionId - chat waiting.
385
- * @param {number} ms - ceiling.
386
- * @param {AbortSignal | undefined} signal - tool cancellation.
387
- * @returns {Promise<boolean>} whether a tab arrived.
388
- */
389
- waitSeat(sessionId, ms, signal) {
390
- if (this.hasSeat(sessionId)) return Promise.resolve(true)
391
- if (ms <= 0) return Promise.resolve(false)
392
- return new Promise((resolve) => {
393
- const done = (value) => {
394
- clearInterval(poll)
395
- signal?.removeEventListener('abort', abort)
396
- resolve(value)
397
- }
398
- const poll = setInterval(() => {
399
- if (this.hasSeat(sessionId)) done(true)
400
- else if (this.refused.has(sessionId)) done(false)
401
- }, 120)
402
- poll.unref?.()
403
- const abort = () => done(false)
404
- signal?.addEventListener('abort', abort)
405
- setTimeout(() => done(this.hasSeat(sessionId)), ms).unref?.()
406
- })
407
- }
408
-
409
- /**
410
- * @param {string} sessionId - owning chat.
77
+ * Take the chat's event stream.
78
+ * @param {string} sessionId - chat the tab belongs to.
411
79
  * @param {import('node:http').ServerResponse} res - open SSE response.
412
- * @param {string} workspace - lease key of the chat's workspace, as the tab reports it.
413
- */
414
- attach(sessionId, res, workspace) {
415
- this.note('attached', { chat: sessionId, reason: String(workspace ?? '') })
416
- const previous = this.seats.get(sessionId)
417
- if (previous !== undefined && previous.res !== res) previous.res.end()
418
- this.seats.set(sessionId, { res, sessionId, workspace })
419
- }
420
-
421
- /**
422
- * Make a folder under the chat's work root, keeping it out of the project's git.
423
- * @param {string} base - chat's working directory.
424
- * @param {string} inner - folder under the work root.
425
- * @returns {Promise<string>} absolute folder to write into.
426
80
  */
427
- async room(base, inner) {
428
- const dir = join(base, inner)
429
- await mkdir(dir, { recursive: true })
430
- await writeFile(join(base, WORK_DIR, '.gitignore'), KEEP).catch(() => {})
431
- return dir
81
+ attach(sessionId, res) {
82
+ this.seats.get(sessionId)?.end()
83
+ this.seats.set(sessionId, res)
432
84
  }
433
85
 
434
86
  /**
435
- * The folder the chat works in, when its tab said so.
436
- * @param {string} sessionId - chat asking.
437
- * @returns {string | undefined} absolute folder.
87
+ * Drop a stream when its chat or its tab goes away.
88
+ * @param {string} sessionId - chat the tab belonged to.
89
+ * @param {import('node:http').ServerResponse} res - response being closed.
438
90
  */
439
- cwdOf(sessionId) {
440
- const key = String(this.seatFor(sessionId)?.workspace ?? '')
441
- if (key.startsWith('cwd:') !== true) return undefined
442
- const dir = key.slice(4)
443
- return dir === '' ? undefined : dir
444
- }
445
-
446
- folderOf(sessionId) {
447
- try {
448
- const dir = String(this.hostFolder?.(sessionId) ?? '')
449
- return dir === '' ? undefined : dir
450
- } catch {
451
- return undefined
452
- }
453
- }
454
-
455
- /** @param {string} sessionId - owning chat. @param {import('node:http').ServerResponse} res - response being closed. */
456
91
  detach(sessionId, res) {
457
- const seat = this.seats.get(sessionId)
458
- if (seat !== undefined && seat.res === res) {
459
- this.note('detached', { chat: sessionId })
460
- this.seats.delete(sessionId)
461
- /* The page that answers next may be a different copy. */
462
- this.native = undefined
463
- }
464
- }
465
-
466
- /** @returns {string[]} chats with a live tab. */
467
- connected() {
468
- return [...this.seats.keys()]
92
+ if (this.seats.get(sessionId) === res) this.seats.delete(sessionId)
469
93
  }
470
94
 
471
95
  /**
472
- * Push one frame to a session's tab.
96
+ * Push one frame to a chat's tab.
473
97
  * @param {string} sessionId - target chat.
474
98
  * @param {unknown} frame - JSON frame.
475
99
  * @returns {boolean} whether a tab received it.
476
100
  */
477
101
  push(sessionId, frame) {
478
- const seat = this.seatFor(sessionId)
102
+ const seat = this.seats.get(sessionId)
479
103
  if (seat === undefined) return false
480
- seat.res.write(`data: ${JSON.stringify(frame)}\n\n`)
104
+ seat.write(`data: ${JSON.stringify(frame)}\n\n`)
481
105
  return true
482
106
  }
483
107
 
484
- /**
485
- * Ask the guest for something and wait for the matching reply.
486
- * @param {string} sessionId - target chat.
487
- * @param {string} tool - operation name the Client understands.
488
- * @param {Record<string, unknown>} args - operation arguments.
489
- * @param {AbortSignal | undefined} signal - cancellation from the tool layer.
490
- * @param {number} timeoutMs - how long the operation may take.
491
- * @returns {Promise<unknown>} the Client's reply value.
492
- */
493
- async call(sessionId, tool, args, signal, timeoutMs = DEFAULT_TIMEOUT_MS) {
494
- if (this.native === false) throw new Error(LEGACY_PAGE)
495
- if (!this.hasSeat(sessionId)) {
496
- this.refused.delete(sessionId)
497
- /* The tab opens at the address the call needs, so a chat never gets an
498
- empty browser beside the page it asked for. */
499
- this.want(sessionId, typeof args?.url === 'string' ? args.url : '')
500
- await this.waitSeat(sessionId, this.openWaitMs, signal)
501
- /* A page that cannot come says so at once, instead of letting the call
502
- wait out the whole opening window. */
503
- if (!this.hasSeat(sessionId)) {
504
- const why = this.refused.get(sessionId)
505
- if (why !== undefined) throw new Error(why)
506
- }
507
- }
508
- try {
509
- return await this.attempt(sessionId, tool, args, signal, timeoutMs)
510
- } catch (error) {
511
- /* One retry after the page came back. */
512
- if (!String(error?.message ?? error).includes('must be attached to the DOM')) throw error
513
- await new Promise((resolve) => setTimeout(resolve, 600))
514
- return this.attempt(sessionId, tool, args, signal, timeoutMs)
515
- }
516
- }
517
-
518
- /**
519
- * One round trip to the page.
520
- * @param {string} sessionId - target chat.
521
- * @param {string} tool - operation the Client understands.
522
- * @param {Record<string, unknown>} args - operation arguments.
523
- * @param {AbortSignal | undefined} signal - cancellation from the tool layer.
524
- * @param {number} timeoutMs - how long the operation may take.
525
- * @returns {Promise<unknown>} the Client's reply value.
526
- */
527
- async attempt(sessionId, tool, args, signal, timeoutMs) {
528
- /* A picture comes from a painted surface, and the surface belongs to the chat
529
- being read. Asking for one from elsewhere would have the page show itself,
530
- which takes the column away from whoever is reading; the answer names the
531
- chat instead. */
532
- if (tool === 'shot' && args?.offscreen !== true) {
533
- const screen = typeof this.client?.frame?.session === 'string' ? this.client.frame.session : ''
534
- if (screen !== '' && screen !== sessionId) throw new Error(NOT_DRAWN)
535
- }
536
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
537
- if (!this.push(sessionId, { type: 'call', id, tool, args, session: sessionId })) {
538
- this.note('call-no-tab', { chat: sessionId, reason: tool })
539
- throw new Error(NO_TAB)
540
- }
541
- this.note('called', { chat: sessionId, reason: tool })
542
- return new Promise((resolve, reject) => {
543
- const timer = setTimeout(() => {
544
- this.waiters.delete(id)
545
- reject(new Error(`the page did not finish ${tool} in time`))
546
- }, timeoutMs)
547
- timer.unref?.()
548
- this.waiters.set(id, { resolve, reject, timer })
549
- signal?.addEventListener('abort', () => {
550
- const waiter = this.waiters.get(id)
551
- if (waiter === undefined) return
552
- clearTimeout(waiter.timer)
553
- this.waiters.delete(id)
554
- reject(new Error('cancelled'))
555
- }, { once: true })
556
- })
557
- }
558
-
559
- /**
560
- * Resolve the waiter a reply frame belongs to.
561
- * @param {{id?: string, ok?: boolean, value?: unknown, error?: string}} frame - reply frame.
562
- * @returns {boolean} whether a waiter took it.
563
- */
564
- settle(frame) {
565
- if (typeof frame.id !== 'string') return false
566
- const waiter = this.waiters.get(frame.id)
567
- if (waiter === undefined) return false
568
- this.waiters.delete(frame.id)
569
- clearTimeout(waiter.timer)
570
- if (frame.ok === false) waiter.reject(new Error(frame.error ?? 'the page refused the call'))
571
- else waiter.resolve(frame.value)
572
- return true
108
+ /** @returns {string[]} chats with a live tab. */
109
+ connected() {
110
+ return [...this.seats.keys()]
573
111
  }
574
112
 
575
- /** Close every seat and reject every waiter. */
113
+ /** Close every stream. */
576
114
  dispose() {
577
- for (const { res } of this.seats.values()) res.end()
115
+ for (const res of this.seats.values()) res.end()
578
116
  this.seats.clear()
579
- for (const waiter of this.waiters.values()) {
580
- clearTimeout(waiter.timer)
581
- waiter.reject(new Error('plugin stopped'))
582
- }
583
- this.waiters.clear()
584
- for (const res of this.watchers) res.end()
585
- this.watchers.clear()
586
- }
587
- }
588
-
589
- /** @returns {Promise<void>} resolves once the proof line is on disk. */
590
- async function record(line) {
591
- const { appendFile } = await import('node:fs/promises')
592
- await appendFile(proofLog, `${JSON.stringify({ at: new Date().toISOString(), ...line })}\n`).catch(() => {})
593
- }
594
-
595
- /**
596
- * Wrap one Client operation as a tool result.
597
- * @param {string} text - what the model reads.
598
- * @returns {{text: string}} tool value.
599
- */
600
- /**
601
- * Turn base64 into bytes without naming a global the tool context may hide.
602
- * @param {string} value - base64 text.
603
- * @returns {Uint8Array} bytes a write accepts.
604
- */
605
- function decodeBase64(value) {
606
- const binary = atob(String(value))
607
- const bytes = new Uint8Array(binary.length)
608
- for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
609
- return bytes
610
- }
611
-
612
- function asValue(text) {
613
- return { text }
614
- }
615
-
616
-
617
- /** The largest saved file the chat reads back in the same result. */
618
- const INLINE_BYTES = 200_000 // ui-copy-ignore
619
- /** The longest text the chat reads back in the same result. */
620
- const INLINE_CHARS = 6_000 // ui-copy-ignore
621
-
622
- /**
623
- * Read back a saved file when it is small and holds text, so the chat can quote
624
- * it straight away. A picture, an archive or a big file stays on disk.
625
- * @param {string} file - absolute path of the saved file.
626
- * @returns {Promise<string>} text to append, empty when the file is not text.
627
- */
628
- async function readableTail(file) {
629
- if (file === '') return ''
630
- const info = await stat(file).catch(() => undefined)
631
- if (info === undefined || info.isFile() !== true || info.size === 0 || info.size > INLINE_BYTES) return ''
632
- const raw = await readFile(file).catch(() => undefined)
633
- if (raw === undefined) return ''
634
- if (raw.subarray(0, 512).includes(0) === true) return '' // ui-copy-ignore
635
- const text = raw.toString('utf8')
636
- if (text.includes('\uFFFD') === true) return ''
637
- const kept = text.length > INLINE_CHARS ? `${text.slice(0, INLINE_CHARS)}\n…` : text
638
- return `\n\n${kept}`
639
- }
640
-
641
- /** @param {string | null} raw - a name the page offered. @returns {string} one safe path segment. */
642
- function safeName(raw) {
643
- const flat = String(raw ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
644
- const clean = flat.replace(/[\u0000-\u001f<>:"|?*]/g, '').trim().slice(0, 120)
645
- return clean === '' || clean === '.' || clean === '..' ? 'download' : clean
646
- }
647
-
648
- /**
649
- * A name the page asked for, unless the folder already holds one.
650
- * @param {string} dir - target folder.
651
- * @param {string} name - wanted file name.
652
- * @returns {Promise<string>} a path no other file holds.
653
- */
654
- async function freePath(dir, name) {
655
- const dot = name.lastIndexOf('.')
656
- const stem = dot > 0 ? name.slice(0, dot) : name
657
- const ext = dot > 0 ? name.slice(dot) : ''
658
- for (let index = 1; index < 1000; index += 1) {
659
- const candidate = join(dir, index === 1 ? name : `${stem}-${index}${ext}`)
660
- if ((await stat(candidate).catch(() => undefined)) === undefined) return candidate
661
117
  }
662
- return join(dir, `${stem}-${Date.now()}${ext}`)
663
118
  }
664
119
 
665
- /** Every tool renders as plain text, and every schema is closed. */
666
- const TEXT_OUTPUT = {
667
- schema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'], additionalProperties: false },
668
- /** @param {unknown} _args - call arguments, unused. @param {unknown} value - tool value. @returns {Array<{type: string, text: string}>} blocks. */
669
- render(_args, value) {
670
- return [{ type: 'text', text: String(value?.text ?? '') }]
671
- },
672
- }
673
-
674
- /**
675
- * Build the tool definitions over one bridge.
676
- * @param {Bridge} bridge - the live bridge.
677
- * @param {Map<string, { dir: string, file: string }>} sinks - folder per live file transfer.
678
- * @returns {Array<Record<string, unknown>>} tool definitions.
679
- */
680
- function tools(bridge, sinks) {
681
- /**
682
- * Run one Client operation for the calling chat.
683
- * @param {string} tool - Client operation.
684
- * @param {unknown} args - arguments.
685
- * @param {any} exec - tool run context.
686
- * @param {number} timeoutMs - operation budget.
687
- * @returns {Promise<{text: string}>} rendered value.
688
- */
689
- const run = async (tool, args, exec, timeoutMs) => {
690
- /* A call without the identity of the chat it belongs to cannot be placed:
691
- guessing one is how a page from another chat got driven. */
692
- const chat = String(exec.agent?.id ?? '')
693
- if (chat === '') throw new Error(NO_CHAT)
694
- const value = await bridge.call(chat, tool, args ?? {}, exec.signal, timeoutMs)
695
- return asValue(typeof value === 'string' ? value : (value?.text ?? JSON.stringify(value ?? null)))
696
- }
697
-
698
- return [
699
- {
700
- name: 'browser_navigate', // ui-copy-ignore
701
- description: 'Open a URL in the sidebar browser of this chat and return the page it shows.',
702
- parameters: {
703
- type: 'object', // ui-copy-ignore
704
- properties: { url: { type: 'string', description: 'Absolute http or https URL.' } },
705
- required: ['url'], // ui-copy-ignore
706
- additionalProperties: false,
707
- },
708
- output: TEXT_OUTPUT, // ui-copy-ignore
709
- timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
710
- isConcurrencySafe: () => false,
711
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
712
- execute: (args, exec) => run('navigate', args, exec, NAVIGATE_TIMEOUT_MS),
713
- },
714
- {
715
- name: 'browser_view', // ui-copy-ignore
716
- description: 'Return what the sidebar browser of this chat shows now: address, title, visible text, and numbered controls.',
717
- parameters: { type: 'object', properties: {}, additionalProperties: false },
718
- output: TEXT_OUTPUT, // ui-copy-ignore
719
- isConcurrencySafe: () => false,
720
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
721
- execute: (args, exec) => run('view', args, exec, DEFAULT_TIMEOUT_MS),
722
- },
723
- {
724
- name: 'browser_click', // ui-copy-ignore
725
- description: 'Click one numbered control in the sidebar browser of this chat and return the page it leads to.',
726
- parameters: {
727
- type: 'object', // ui-copy-ignore
728
- properties: { ref: { type: 'integer', description: 'Number shown by the latest browser_view.' } },
729
- required: ['ref'], // ui-copy-ignore
730
- additionalProperties: false,
731
- },
732
- output: TEXT_OUTPUT, // ui-copy-ignore
733
- timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
734
- isConcurrencySafe: () => false,
735
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
736
- execute: (args, exec) => run('click', args, exec, NAVIGATE_TIMEOUT_MS),
737
- },
738
- {
739
- name: 'browser_type', // ui-copy-ignore
740
- description: 'Type into one numbered field in the sidebar browser of this chat, optionally pressing Enter.',
741
- parameters: {
742
- type: 'object', // ui-copy-ignore
743
- properties: {
744
- ref: { type: 'integer', description: 'Number shown by the latest browser_view.' },
745
- text: { type: 'string', description: 'Text to enter. Replaces the field content.' },
746
- submit: { type: 'boolean', description: 'Press Enter after typing.' },
747
- },
748
- required: ['ref', 'text'], // ui-copy-ignore
749
- additionalProperties: false,
750
- },
751
- output: TEXT_OUTPUT, // ui-copy-ignore
752
- timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
753
- isConcurrencySafe: () => false,
754
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
755
- execute: (args, exec) => run('type', args, exec, NAVIGATE_TIMEOUT_MS),
756
- },
757
- {
758
- name: 'browser_selection', // ui-copy-ignore
759
- description: 'Return the text selected in the sidebar browser of this chat, so it can be quoted here.',
760
- parameters: { type: 'object', properties: {}, additionalProperties: false },
761
- output: TEXT_OUTPUT, // ui-copy-ignore
762
- isConcurrencySafe: () => false,
763
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} the selection. */
764
- execute: (args, exec) => run('selection', args, exec, DEFAULT_TIMEOUT_MS),
765
- },
766
- {
767
- name: 'browser_copy', // ui-copy-ignore
768
- description: 'Copy the page selection, or one numbered control\'s text, to the clipboard so it can be pasted into this chat.',
769
- parameters: {
770
- type: 'object', // ui-copy-ignore
771
- properties: { ref: { type: 'integer', description: 'Number shown by the latest browser_view. Omit to copy the selection.' } },
772
- additionalProperties: false,
773
- },
774
- output: TEXT_OUTPUT, // ui-copy-ignore
775
- isConcurrencySafe: () => false,
776
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} copy result. */
777
- execute: (args, exec) => run('copy', args, exec, DEFAULT_TIMEOUT_MS),
778
- },
779
- {
780
- name: 'browser_script', // ui-copy-ignore
781
- description: 'Evaluate JavaScript in the sidebar browser of this chat and return its last expression. Use it when no control reaches the data you need.',
782
- parameters: {
783
- type: 'object', // ui-copy-ignore
784
- properties: { code: { type: 'string', description: 'JavaScript source. The last expression is the result.' } },
785
- required: ['code'], // ui-copy-ignore
786
- additionalProperties: false,
787
- },
788
- output: TEXT_OUTPUT, // ui-copy-ignore
789
- timeoutMs: NAVIGATE_TIMEOUT_MS,
790
- isConcurrencySafe: () => false,
791
- /** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} result. */
792
- execute: (args, exec) => run('script', args, exec, NAVIGATE_TIMEOUT_MS),
793
- },
794
- {
795
- name: 'browser_screenshot',
796
- description: 'Save a PNG of what the sidebar browser of this chat shows now, and return the file path.',
797
- parameters: {
798
- type: 'object', // ui-copy-ignore
799
- properties: {
800
- offscreen: {
801
- type: 'boolean', // ui-copy-ignore
802
- description: 'Take the image without bringing this chat to the front.',
803
- },
804
- },
805
- additionalProperties: false,
806
- }, // ui-copy-ignore
807
- output: TEXT_OUTPUT,
808
- isConcurrencySafe: () => false,
809
- /**
810
- * @param {any} args - call arguments.
811
- * @param {any} exec - run context.
812
- * @returns {Promise<{text: string}>} absolute path of the saved PNG.
813
- */
814
- execute: async (args, exec) => {
815
- const value = await bridge.call(
816
- chatOf(exec),
817
- 'shot',
818
- { offscreen: args?.offscreen === true },
819
- exec.signal,
820
- DEFAULT_TIMEOUT_MS,
821
- )
822
- const png = value?.png
823
- if (typeof png !== 'string' || png === '') throw new Error('the page returned no image')
824
- const chat = chatOf(exec)
825
- const base = workBase(bridge, chat, exec.agent?.cwd)
826
- const dir = await bridge.room(base, SHOT_DIR)
827
- const file = join(dir, `shot-${Date.now()}.png`)
828
- await writeFile(file, decodeBase64(png))
829
- return asValue(file)
830
- },
831
- },
832
- {
833
- name: 'browser_download', // ui-copy-ignore
834
- description: "Save a file the sidebar browser of this chat offers into this chat's project folder and return its path.",
835
- parameters: {
836
- type: 'object', // ui-copy-ignore
837
- properties: {
838
- url: { type: 'string', description: 'Address of the file, when the page shows one.' },
839
- ref: { type: 'integer', description: 'Number shown by the latest browser_view, for a control that links to the file.' },
840
- name: { type: 'string', description: 'File name to save under, when the page offers none.' },
841
- },
842
- additionalProperties: false,
843
- }, // ui-copy-ignore
844
- output: TEXT_OUTPUT,
845
- timeoutMs: DOWNLOAD_TIMEOUT_MS + 5_000,
846
- isConcurrencySafe: () => false,
847
- /**
848
- * @param {any} args - call arguments.
849
- * @param {any} exec - run context.
850
- * @returns {Promise<{text: string}>} absolute path of the saved file.
851
- */
852
- execute: async (args, exec) => {
853
- const chat = chatOf(exec)
854
- const base = workBase(bridge, chat, exec.agent?.cwd)
855
- const dir = await bridge.room(base, DOWNLOAD_DIR)
856
- /* The page never names a folder: this ticket is the one folder its bytes
857
- may reach, and it dies with the call. */
858
- const ticket = randomUUID() // ui-copy-ignore
859
- sinks.set(ticket, { dir, file: '' })
860
- try {
861
- const value = await bridge.call(
862
- chatOf(exec),
863
- 'download',
864
- { ticket, url: args?.url, ref: args?.ref, name: args?.name },
865
- exec.signal,
866
- DOWNLOAD_TIMEOUT_MS,
867
- )
868
- const saved = typeof value === 'string' ? value : String(value?.text ?? '')
869
- const file = typeof value === 'object' && value !== null && typeof value.path === 'string' ? value.path : ''
870
- const size = typeof value === 'object' && value !== null && Number.isFinite(value.bytes) ? value.bytes : undefined
871
- const line = file === ''
872
- ? saved
873
- : `Saved ${file} (${size ?? (await stat(file).catch(() => undefined))?.size ?? 0} bytes)`
874
- /* A page that saved a readable file hands it over here as well, so the
875
- chat can quote it without a second trip through the machine. */
876
- return asValue(line + (await readableTail(file)))
877
- } finally {
878
- sinks.delete(ticket)
879
- }
880
- },
881
- },
882
- {
883
- name: 'browser_upload', // ui-copy-ignore
884
- description: 'Attach a file from this machine to one numbered file field in the sidebar browser of this chat.',
885
- parameters: {
886
- type: 'object', // ui-copy-ignore
887
- properties: {
888
- ref: { type: 'integer', description: 'Number shown by the latest browser_view.' },
889
- path: { type: 'string', description: 'Absolute path of the file to attach.' },
890
- },
891
- required: ['ref', 'path'], // ui-copy-ignore
892
- additionalProperties: false,
893
- },
894
- output: TEXT_OUTPUT, // ui-copy-ignore
895
- timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
896
- isConcurrencySafe: () => false,
897
- /**
898
- * @param {any} args - call arguments.
899
- * @param {any} exec - run context.
900
- * @returns {Promise<{text: string}>} page state after the attach.
901
- */
902
- execute: async (args, exec) => {
903
- const file = resolve(String(args.path ?? ''))
904
- const info = await stat(file).catch(() => undefined)
905
- if (info === undefined || !info.isFile()) throw new Error(`no file at ${file}`)
906
- if (info.size > MAX_UPLOAD_BYTES) throw new Error(`the file is ${info.size} bytes; the limit is ${MAX_UPLOAD_BYTES}`)
907
- const bytes = await readFile(file)
908
- const name = file.slice(file.lastIndexOf('/') + 1)
909
- const value = await bridge.call(
910
- chatOf(exec),
911
- 'upload',
912
- { ref: args.ref, name, type: mediaType(file), base64: bytes.toString('base64') },
913
- exec.signal,
914
- NAVIGATE_TIMEOUT_MS,
915
- )
916
- return asValue(typeof value === 'string' ? value : String(value?.text ?? ''))
917
- },
918
- },
919
- ]
920
- }
120
+ /** Page read: address, title, controls, then text, as JSON the Host parses. */ // ui-copy-ignore
121
+ /* The read model and the tool surface live in their own files: this module is
122
+ the Host half (routes, bridge, lifetimes), not the model's vocabulary. */ // ui-copy-ignore
123
+ import { sidecarTools } from './tools.js?v=1'
921
124
 
922
125
  export const name = 'dsh-inner-browser-use'
923
126
  export const inject = ['webServer']
924
127
 
925
128
  /**
926
- * Mount the bridge and the tool surface.
129
+ * Mount the mirror routes and the tool surface.
927
130
  * @param {any} ctx - plugin context.
928
- * @param {{ proofLog?: string }} [config] - optional overrides.
929
131
  */
930
- export function apply(ctx, config = {}) {
931
- if (typeof config.proofLog === 'string' && config.proofLog !== '') proofLog = config.proofLog
132
+ export function apply(ctx) {
932
133
  const bridge = new Bridge()
933
- /** @type {Map<string, { dir: string, file: string }>} */
934
- const sinks = new Map()
935
- /* Files a page saved on its own: one row per transfer, gone when the bytes are in. */
936
- const landings = new Map()
937
- if (Number.isInteger(config.openWaitMs)) bridge.openWaitMs = config.openWaitMs
134
+ /* The sidecar is a browser the Host owns outright: a real Chrome with its own // ui-copy-ignore
135
+ profile, shown in the column as a screencast. It outlives a plugin reload,
136
+ and it does not need the chat to be on screen to do work. */
137
+ const sidecar = new Sidecar({ logger: ctx.logger })
138
+ /** @type {{refs: any[], at: number, last?: any}} */
139
+ const sidecarRefs = { refs: [], at: 0 }
938
140
  ctx.effect(() => () => bridge.dispose(), 'dsh-inner-browser-use: bridge lifetime')
939
- /* Each chat works in a folder the app already knows; asking it is cheaper
940
- than waiting for the page to say so. */
941
- bridge.hostFolder = (chat) => {
942
- try {
943
- const session = ctx.get('sessions')?.get?.(chat)
944
- if (session === undefined || session === null) return undefined
945
- const dir = String(ctx.get('workingDirectory')?.get?.(session) ?? '')
946
- return dir === '' ? undefined : dir
947
- } catch {
948
- return undefined
141
+ /* Closing the browser cleanly on quit is what keeps a sign-in alive: a
142
+ killed Chrome never writes the cookies a page set this minute. */
143
+ ctx.effect(() => {
144
+ const timer = setInterval(() => {
145
+ if (!sidecar.alive) return
146
+ void sidecar.saveSessionCookies()
147
+ }, 60_000)
148
+ timer.unref?.()
149
+ return () => {
150
+ clearInterval(timer)
151
+ void sidecar.shutdown().catch(() => {})
949
152
  }
950
- }
153
+ }, 'dsh-inner-browser-use: sidecar lifetime')
951
154
 
952
155
  /** @param {import('node:http').IncomingMessage} req - request. @param {import('node:http').ServerResponse} res - response. */
953
156
  const handler = async (req, res) => {
@@ -955,304 +158,211 @@ export function apply(ctx, config = {}) {
955
158
  const path = url.pathname.slice(ROUTE.length) || '/'
956
159
  const sessionId = url.searchParams.get('session') ?? ''
957
160
 
958
- if (path === '/downlink' && req.method === 'GET') {
959
- res.writeHead(200, {
960
- 'content-type': 'text/event-stream; charset=utf-8',
961
- 'cache-control': 'no-cache, no-transform',
962
- connection: 'keep-alive',
963
- 'x-accel-buffering': 'no',
964
- })
965
- res.write('retry: 2000\n\n')
966
- bridge.attach(sessionId, res, url.searchParams.get('workspace') ?? '')
967
- res.write(`data: ${JSON.stringify({ type: 'hello', session: sessionId, at: Date.now() })}\n\n`)
968
- void record({ kind: 'downlink-open', session: sessionId, origin: req.headers.origin ?? null })
969
- const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
970
- beat.unref?.()
971
- const close = () => {
972
- clearInterval(beat)
973
- bridge.detach(sessionId, res)
974
- void record({ kind: 'downlink-close', session: sessionId })
975
- }
976
- req.on('close', close)
977
- res.on('error', close)
978
- return
161
+ /** @param {number} code - status. @param {unknown} value - body. */
162
+ const json = (code, value) => {
163
+ res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
164
+ res.end(JSON.stringify(value))
979
165
  }
980
166
 
981
- if (path === '/watch' && req.method === 'GET') {
167
+ if (path === '/sidecar/frames' && req.method === 'GET') {
168
+ /* The column is global and only one chat may hold the mirror: a chat that
169
+ does not own it is refused here, so a stale tab cannot stream a picture
170
+ 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
+ }
982
175
  res.writeHead(200, {
983
176
  'content-type': 'text/event-stream; charset=utf-8',
984
177
  'cache-control': 'no-cache, no-transform',
985
178
  connection: 'keep-alive',
986
179
  'x-accel-buffering': 'no',
987
180
  })
181
+ res.socket?.setNoDelay?.(true)
988
182
  res.write('retry: 2000\n\n')
989
- bridge.watch(res)
990
- res.write(`data: ${JSON.stringify({ type: 'watch-alive', at: Date.now() })}\n\n`)
991
- void record({ kind: 'watch-open' })
992
- const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
993
- beat.unref?.()
994
- const close = () => {
995
- clearInterval(beat)
996
- bridge.unwatch(res)
997
- }
998
- req.on('close', close)
999
- res.on('error', close)
1000
- return
1001
- }
1002
-
1003
- if (path === '/uplink' && req.method === 'POST') {
1004
- let frame
183
+ /** @type {() => void} */
184
+ let detach = () => {}
185
+ const drop = () => detach()
186
+ req.on('close', drop)
187
+ res.on('error', drop)
1005
188
  try {
1006
- frame = JSON.parse(await readBody(req))
189
+ detach = await sidecar.watch((frame) => {
190
+ /* A viewer that cannot keep up loses frames instead of making the
191
+ page wait: the newest picture is the only one worth sending. */
192
+ if (res.writableLength > 3 * 1024 * 1024) return
193
+ res.write(`data: ${JSON.stringify(frame)}\n\n`)
194
+ })
195
+ const info = await sidecar.state()
196
+ res.write(`data: ${JSON.stringify({ type: 'hello', ...info })}\n\n`)
1007
197
  } catch (error) {
1008
- res.writeHead(400, { 'content-type': 'text/plain' })
1009
- res.end(String(error?.message ?? error))
1010
- return
198
+ res.write(`data: ${JSON.stringify({ type: 'failed', text: String(error?.message ?? error) })}\n\n`)
1011
199
  }
1012
- await record({ kind: 'uplink', session: sessionId, frame })
1013
- if (frame?.type === 'client-alive') {
1014
- /* A heartbeat taken on load carries this; a page that never says it
1015
- drives the product's browser is a copy loaded before that existed. */
1016
- if (frame.phase === 'alive') bridge.native = frame.native === true
1017
- bridge.client = { ms: Date.now(), frame }
1018
- bridge.trail.push({ at: new Date().toISOString(), frame })
1019
- if (bridge.trail.length > 24) bridge.trail.shift()
1020
- bridge.noteOpen(frame)
1021
- bridge.noteStep(frame)
1022
- }
1023
- bridge.settle(frame)
1024
- res.writeHead(200, { 'content-type': 'application/json' })
1025
- res.end(JSON.stringify({ ok: true, epoch: bridge.epoch }))
200
+ const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
201
+ beat.unref?.()
202
+ req.on('close', () => clearInterval(beat))
1026
203
  return
1027
204
  }
1028
205
 
1029
- if (path === '/want' && req.method === 'POST') {
1030
- /* Asks the page to open this chat's browser tab, the same ask a tool makes.
1031
- Gated so a page cannot be steered by another site. */
1032
- if (req.headers['x-dshib-want'] !== '1') {
1033
- res.writeHead(403, { 'content-type': 'text/plain' })
1034
- res.end('forbidden')
1035
- return
1036
- }
1037
- const asked = url.searchParams.get('session') ?? ''
1038
- bridge.want(asked)
1039
- res.writeHead(200, { 'content-type': 'application/json' })
1040
- res.end(JSON.stringify({ ok: true, session: asked, watchers: bridge.watchers.size }))
206
+ if (path === '/sidecar/state' && req.method === 'GET') {
207
+ json(200, { ...(await sidecar.state()), owner: bridge.owner })
1041
208
  return
1042
209
  }
1043
210
 
1044
- if (path === '/push' && req.method === 'POST') {
1045
- let frame
211
+ if (path === '/sidecar/open' && req.method === 'POST') {
212
+ let body
1046
213
  try {
1047
- frame = JSON.parse(await readBody(req))
214
+ body = JSON.parse(await readBody(req))
215
+ } catch {
216
+ body = {}
217
+ }
218
+ try {
219
+ const address = String(body.url ?? '')
220
+ /* The ask must name the chat it belongs to: a request that cannot say
221
+ who it is for never gets the mirror. */
222
+ const who = String(body.session ?? '')
223
+ if (who === '') {
224
+ json(400, { ok: false, text: 'which chat asked for it' })
225
+ return
226
+ }
227
+ await sidecar.ensure({
228
+ url: address === '' ? undefined : address,
229
+ headless: body.headless === undefined ? undefined : body.headless === true,
230
+ })
231
+ /* The mirror belongs to the chat that asked for it; any earlier owner
232
+ loses it and its tab. */
233
+ bridge.claim(who)
234
+ bridge.push(who, { type: 'sidecar-wanted', session: who })
235
+ json(200, { ok: true, ...(await sidecar.state()), owner: bridge.owner })
1048
236
  } catch (error) {
1049
- res.writeHead(400, { 'content-type': 'text/plain' })
1050
- res.end(String(error?.message ?? error))
1051
- return
237
+ json(500, { ok: false, text: String(error?.message ?? error) })
1052
238
  }
1053
- const delivered = bridge.push(sessionId, frame)
1054
- res.writeHead(delivered ? 200 : 409, { 'content-type': 'application/json' })
1055
- res.end(JSON.stringify({ delivered }))
1056
239
  return
1057
240
  }
1058
241
 
1059
- if (path === '/cookies' && (req.method === 'GET' || req.method === 'POST')) {
1060
- const key = url.searchParams.get('key') ?? ''
1061
- if (key === '') {
1062
- res.writeHead(400, { 'content-type': 'text/plain' })
1063
- res.end('key required')
1064
- return
1065
- }
1066
- if (req.method === 'GET') {
1067
- const cookies = await readCookies(key)
1068
- res.writeHead(200, { 'content-type': 'application/json' })
1069
- res.end(JSON.stringify({ cookies }))
1070
- return
1071
- }
242
+ if (path === '/sidecar/input' && req.method === 'POST') {
1072
243
  let body
1073
244
  try {
1074
245
  body = JSON.parse(await readBody(req))
1075
- } catch (error) {
1076
- res.writeHead(400, { 'content-type': 'text/plain' })
1077
- res.end(String(error?.message ?? error))
246
+ } catch {
247
+ json(400, { ok: false, text: 'bad frame' })
1078
248
  return
1079
249
  }
1080
- const kept = await writeCookies(key, body?.cookies)
1081
- await record({ kind: 'cookies', at: new Date().toISOString(), kept })
1082
- res.writeHead(200, { 'content-type': 'application/json' })
1083
- res.end(JSON.stringify({ kept }))
1084
- return
1085
- }
1086
-
1087
- if (path === '/state' && req.method === 'GET') {
1088
- res.writeHead(200, { 'content-type': 'application/json' })
1089
- res.end(JSON.stringify({
1090
- connected: bridge.connected(),
1091
- pending: bridge.waiters.size,
1092
- /* Why the last few answers went where they did. */
1093
- events: bridge.events.slice(-60),
1094
- asking: bridge.asking === undefined ? null : { chat: bridge.asking.chat, at: new Date(bridge.asking.at).toISOString() },
1095
- client: clientState(bridge.client),
1096
- trail: bridge.trail.map((entry) => entry.frame),
1097
- /* Where a chat's files would land, when its tab told us the folder. */
1098
- folders: Object.fromEntries(bridge.connected().map((chat) => [chat, bridge.cwdOf(chat) ?? bridge.folderOf(chat) ?? ''])),
1099
- /* Lets a page that is still running an older bundle replace itself,
1100
- and tells it which version it should be running. */
1101
- clientBuild: clientBuild(),
1102
- version: VERSION,
1103
- }))
1104
- return
1105
- }
1106
-
1107
- if (path === '/grab' && req.method === 'POST') {
1108
- const sink = sinks.get(url.searchParams.get('ticket') ?? '')
1109
- if (sink === undefined) {
1110
- res.writeHead(404, { 'content-type': 'text/plain' })
1111
- res.end('unknown transfer')
250
+ /* Input follows the owner: a chat that lost the mirror cannot act on the
251
+ 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 })
1112
254
  return
1113
255
  }
1114
- const offset = Number(url.searchParams.get('offset') ?? '0')
1115
- if (sink.file === '') sink.file = await freePath(sink.dir, safeName(url.searchParams.get('name')))
1116
- const stream = createWriteStream(sink.file, { flags: offset > 0 ? 'a' : 'w' })
1117
256
  try {
1118
- await new Promise((settle, fail) => {
1119
- stream.on('finish', settle)
1120
- stream.on('error', fail)
1121
- req.on('error', fail)
1122
- req.pipe(stream)
1123
- })
257
+ const page = await sidecar.ensure()
258
+ const kind = String(body.kind ?? '')
259
+ const x = Number(body.x ?? 0)
260
+ const y = Number(body.y ?? 0)
261
+ let cursor
262
+ if (kind === 'hover') cursor = await page.hover({ x, y })
263
+ else if (kind === 'move') await page.mouse('mouseMoved', { x, y })
264
+ else if (kind === 'click') {
265
+ const clickCount = body.double === true ? 2 : 1
266
+ await page.mouse('mouseMoved', { x, y })
267
+ await page.mouse('mousePressed', { x, y, clickCount })
268
+ await page.mouse('mouseReleased', { x, y, clickCount })
269
+ if (body.double === true) {
270
+ await page.mouse('mousePressed', { x, y, clickCount: 2 })
271
+ await page.mouse('mouseReleased', { x, y, clickCount: 2 })
272
+ }
273
+ } else if (kind === 'wheel') await page.wheel({ x, y, dx: Number(body.dx ?? 0), dy: Number(body.dy ?? 0) })
274
+ else if (kind === 'text') await page.insertText(String(body.text ?? ''))
275
+ else if (kind === 'key') {
276
+ const name = String(body.key ?? '')
277
+ const known = KEYS[name]
278
+ const spec = {
279
+ key: name,
280
+ code: String(body.code ?? known?.code ?? name),
281
+ keyCode: Number(body.keyCode ?? 0) || known?.keyCode || 0,
282
+ text: body.text === undefined ? known?.text : String(body.text),
283
+ modifiers: Number(body.modifiers ?? 0),
284
+ }
285
+ await page.key('keyDown', spec)
286
+ await page.key('keyUp', { ...spec, text: undefined })
287
+ }
288
+ json(200, cursor === undefined ? { ok: true } : { ok: true, cursor })
1124
289
  } catch (error) {
1125
- res.writeHead(500, { 'content-type': 'text/plain' })
1126
- res.end(String(error?.message ?? error))
1127
- return
290
+ json(500, { ok: false, text: String(error?.message ?? error) })
1128
291
  }
1129
- const info = await stat(sink.file)
1130
- res.writeHead(200, { 'content-type': 'application/json' })
1131
- res.end(JSON.stringify({ ok: true, path: sink.file, bytes: info.size }))
1132
292
  return
1133
293
  }
1134
294
 
1135
- if (path === '/page-reload' && req.method === 'POST') {
1136
- /* The window's own reload, so a page that cannot replace itself yet is
1137
- replaced without anyone pressing anything. It answers with how it got
1138
- there; a process that cannot reach a window says so and does nothing. */
1139
- if (req.headers['x-dshib-page'] !== '1') {
1140
- res.writeHead(403, { 'content-type': 'text/plain' })
1141
- res.end('forbidden')
295
+ if (path === '/sidecar/nav' && req.method === 'POST') {
296
+ let body
297
+ try {
298
+ body = JSON.parse(await readBody(req))
299
+ } catch {
300
+ json(400, { ok: false, text: 'bad frame' })
301
+ return
302
+ }
303
+ /* Navigation follows the owner too: a lost tab cannot steer the page. */
304
+ if (body.session !== undefined && String(body.session) !== '' && bridge.owner !== '' && String(body.session) !== bridge.owner) {
305
+ json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
1142
306
  return
1143
307
  }
1144
- let how = 'no-electron'
1145
- let why = ''
1146
308
  try {
1147
- /* The main process answers to a plain require; the module form hands
1148
- back the executable's path instead of the API. */
1149
- let electron = null
1150
- try {
1151
- const { createRequire } = await import('node:module')
1152
- electron = createRequire(import.meta.url)('electron')
1153
- } catch (error) {
1154
- electron = await import('electron')
1155
- }
1156
- const api = electron?.BrowserWindow === undefined ? electron?.default : electron
1157
- const windows = api?.BrowserWindow?.getAllWindows?.() ?? []
1158
- why = `${api?.BrowserWindow === undefined ? 'no-BrowserWindow' : 'windows:' + windows.length} probe:${typeof electron}:${typeof api}:${typeof api?.default}`
1159
- const win = windows.find((each) => each?.webContents !== undefined && each.webContents.isDestroyed?.() !== true)
1160
- if (win !== undefined) {
1161
- how = 'window'
1162
- /* From the disk, not from the copy the last load left behind. */
1163
- setTimeout(() => {
1164
- try {
1165
- if (typeof win.webContents.reloadIgnoringCache === 'function') win.webContents.reloadIgnoringCache()
1166
- else win.webContents.reload()
1167
- } catch {
1168
- win.webContents.reload()
1169
- }
1170
- }, 50).unref?.()
309
+ const page = await sidecar.ensure()
310
+ const op = String(body.op ?? 'goto')
311
+ if (op === 'reload') json(200, { ok: true, ...(await page.reload()) })
312
+ else if (op === 'back') json(200, { ok: true, ...(await page.history(-1)) })
313
+ else if (op === 'forward') json(200, { ok: true, ...(await page.history(1)) })
314
+ else {
315
+ const address = String(body.url ?? '')
316
+ const withScheme = /^[a-z]+:/i.test(address) ? address : `https://${address}`
317
+ json(200, { ok: true, ...(await page.navigate(withScheme)) })
1171
318
  }
1172
319
  } catch (error) {
1173
- how = 'no-electron'
1174
- why = String(error?.message ?? error).slice(0, 120)
320
+ json(500, { ok: false, text: String(error?.message ?? error) })
1175
321
  }
1176
- res.writeHead(200, { 'content-type': 'application/json' })
1177
- res.end(JSON.stringify({ ok: how !== 'no-electron', how, why, electron: process.versions.electron ?? null }))
1178
322
  return
1179
323
  }
1180
324
 
1181
- if (path === '/land' && req.method === 'POST') {
1182
- /* A file the page saved on its own, written where that chat works. The
1183
- header keeps another site out; the chat names the folder it works in. */
1184
- if (req.headers['x-dshib-land'] !== '1') {
1185
- res.writeHead(403, { 'content-type': 'text/plain' })
1186
- res.end('forbidden')
1187
- return
1188
- }
1189
- const chat = url.searchParams.get('session') ?? ''
1190
- const id = url.searchParams.get('id') ?? ''
1191
- const offset = Number(url.searchParams.get('offset') ?? '0')
1192
- let landing = landings.get(id)
1193
- if (landing === undefined) {
1194
- const base = workBase(bridge, chat)
1195
- const dir = await bridge.room(base, DOWNLOAD_DIR)
1196
- landing = { dir, file: await freePath(dir, safeName(url.searchParams.get('name'))) }
1197
- landings.set(id, landing)
1198
- if (landings.size > 32) {
1199
- const oldest = landings.keys().next()
1200
- if (oldest.done !== true) landings.delete(oldest.value)
1201
- }
1202
- }
1203
- const stream = createWriteStream(landing.file, { flags: offset > 0 ? 'a' : 'w' })
1204
- try {
1205
- await new Promise((settle, fail) => {
1206
- stream.on('finish', settle)
1207
- stream.on('error', fail)
1208
- req.on('error', fail)
1209
- req.pipe(stream)
1210
- })
1211
- } catch (error) {
1212
- res.writeHead(500, { 'content-type': 'text/plain' })
1213
- res.end(String(error?.message ?? error))
1214
- return
1215
- }
1216
- const info = await stat(landing.file)
1217
- if (url.searchParams.get('final') === '1') landings.delete(id)
1218
- res.writeHead(200, { 'content-type': 'application/json' })
1219
- res.end(JSON.stringify({ ok: true, path: landing.file, bytes: info.size }))
325
+ if (path === '/sidecar/stop' && req.method === 'POST') {
326
+ await sidecar.shutdown()
327
+ json(200, { ok: true })
1220
328
  return
1221
329
  }
1222
330
 
1223
- res.writeHead(404, { 'content-type': 'text/plain' })
1224
- res.end('not found')
1225
- }
1226
-
1227
- /* The page compares this with what it last loaded: a page still running an
1228
- older bundle reloads itself once instead of waiting for a manual refresh. */
1229
- const clientFile = join(dirname(fileURLToPath(import.meta.url)), 'client.js')
1230
- /** What this half was published as; a page compares it with its own copy. */
1231
- const VERSION = (() => {
1232
- try {
1233
- return String(JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version ?? '')
1234
- } catch {
1235
- return ''
1236
- }
1237
- })()
1238
- let buildMark = ''
1239
- let buildStamp = -1
1240
- const clientBuild = () => {
1241
- try {
1242
- const stamp = statSync(clientFile).mtimeMs
1243
- if (stamp === buildStamp && buildMark !== '') return buildMark
1244
- buildMark = createHash('sha256').update(readFileSync(clientFile)).digest('hex').slice(0, 16)
1245
- buildStamp = stamp
1246
- } catch {
1247
- buildMark = buildMark === '' ? 'unknown' : buildMark
331
+ if (path === '/downlink' && req.method === 'GET') {
332
+ res.writeHead(200, {
333
+ 'content-type': 'text/event-stream; charset=utf-8',
334
+ 'cache-control': 'no-cache, no-transform',
335
+ connection: 'keep-alive',
336
+ 'x-accel-buffering': 'no',
337
+ })
338
+ res.write('retry: 2000\n\n')
339
+ bridge.attach(sessionId, res)
340
+ res.write(`data: ${JSON.stringify({ type: 'hello', session: sessionId, at: Date.now() })}\n\n`)
341
+ const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
342
+ beat.unref?.()
343
+ const close = () => {
344
+ clearInterval(beat)
345
+ bridge.detach(sessionId, res)
346
+ }
347
+ req.on('close', close)
348
+ res.on('error', close)
349
+ return
1248
350
  }
1249
- return buildMark
351
+
352
+ json(404, { ok: false, text: 'no such route' })
1250
353
  }
1251
354
 
1252
- ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }), 'dsh-inner-browser-use: bridge route')
355
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }), 'dsh-inner-browser-use: mirror route')
1253
356
 
1254
357
  ctx.inject(['tools'], (scope) => {
1255
- const disposers = tools(bridge, sinks).map((definition) => scope.tools.register(definition))
358
+ const disposers = sidecarTools(sidecar, sidecarRefs, {
359
+ /* The mirror changes hands here: the calling chat owns it from now on,
360
+ and every earlier owner loses it. */
361
+ want: (sessionId) => {
362
+ bridge.claim(sessionId)
363
+ bridge.push(sessionId, { type: 'sidecar-wanted', session: sessionId })
364
+ },
365
+ }).map((definition) => scope.tools.register(definition))
1256
366
  scope.effect(() => () => {
1257
367
  for (const dispose of disposers) dispose()
1258
368
  }, 'dsh-inner-browser-use: tool surface')
@@ -1264,14 +374,14 @@ export function apply(ctx, config = {}) {
1264
374
  name: 'browser-in-this-chat',
1265
375
  order: 858,
1266
376
  text: [
1267
- '## 网页任务:用本会话右栏的内置浏览器做,不问直接开始',
377
+ '## 网页任务:用 sidecar 浏览器做,不问直接开始',
1268
378
  '',
1269
- '人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成 …」时,直接调用 browser_* 工具在本会话右栏操作;不要问要不要打开,也不要另起外部 Chrome。右栏归人所有,做完保留,不主动关。',
1270
- '固定顺序:`browser_navigate` → `browser_view`(拿编号控件与可见文字)→ `browser_click` / `browser_type` → 再 `browser_view` 直到出现要的结果;页面要等的地方重复读,不 sleep。',
1271
- '要交给网页的文件用 `browser_upload`;网页产出的文件用 `browser_download` 存进项目目录,文本用 `browser_copy`,最后在本轮回复里给绝对路径。',
379
+ '人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成 …」时,直接用 `sidecar_*` 工具;不要问要不要打开,也不要另起一个外部 Chrome。它的画面会出现在本会话右栏,做完保留,不主动关。',
380
+ '先接着用已有的页:`sidecar_view`(拿编号控件与可见文字)→ `sidecar_click` / `sidecar_type` → 再 `sidecar_view` 直到出现要的结果;页面要等就重复读,不 sleep。`sidecar_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
381
+ '要保存页面样子用 `sidecar_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
1272
382
  ].join('\n'),
1273
383
  }), 'dsh-inner-browser-use: system prompt section')
1274
384
  }
1275
385
 
1276
- ctx.logger?.info?.('dsh-inner-browser-use: bridge on %s', ROUTE)
386
+ ctx.logger?.info?.('dsh-inner-browser-use: mirror on %s', ROUTE)
1277
387
  }