dsh-builtin-browser 0.2.0 → 0.3.1
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/README.en.md +50 -5
- package/README.md +62 -10
- package/client.js +44 -8
- package/desktop-bridge/install.mjs +98 -0
- package/desktop-bridge/plugin-browser-bridge.js +385 -0
- package/lib/browser-electron/bridge-connection.d.ts +58 -0
- package/lib/browser-electron/bridge-connection.js +133 -0
- package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
- package/lib/browser-electron/desktop-bridge-host.js +297 -0
- package/lib/browser-electron/entry.js +81 -13
- package/lib/browser-electron/history-store.d.ts +5 -1
- package/lib/browser-electron/history-store.js +12 -1
- package/lib/browser-electron/provider.d.ts +42 -1
- package/lib/browser-electron/provider.js +96 -24
- package/lib/browser-electron/remote-host.d.ts +9 -1
- package/lib/browser-electron/remote-host.js +17 -3
- package/lib/browser-electron/settings-store.d.ts +5 -0
- package/lib/browser-electron/settings-store.js +24 -1
- package/lib/browser-electron/system-browser.d.ts +132 -0
- package/lib/browser-electron/system-browser.js +380 -0
- package/lib/browser-electron/virtual-cursor.d.ts +27 -2
- package/lib/browser-electron/virtual-cursor.js +77 -7
- package/lib/tool-browser/index.js +51 -15
- package/package.json +2 -1
- package/src/browser-electron/bridge-connection.ts +145 -0
- package/src/browser-electron/desktop-bridge-host.ts +305 -0
- package/src/browser-electron/entry.ts +84 -13
- package/src/browser-electron/history-store.ts +11 -2
- package/src/browser-electron/provider.ts +124 -25
- package/src/browser-electron/remote-host.ts +17 -2
- package/src/browser-electron/settings-store.ts +30 -1
- package/src/browser-electron/system-browser.ts +371 -0
- package/src/browser-electron/virtual-cursor.ts +76 -6
- package/src/tool-browser/index.ts +51 -15
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive the browser the user already has (Chrome / Edge / any Chromium) instead of
|
|
3
|
+
* the Electron we ship — the same approach Codex Browser Use takes.
|
|
4
|
+
*
|
|
5
|
+
* WHY
|
|
6
|
+
* The bundled Electron is a private copy with its own profile, so it can never see
|
|
7
|
+
* the logins the user already has in Chrome, and on a managed machine the user may
|
|
8
|
+
* simply prefer their own browser. Launching their browser with a *separate*
|
|
9
|
+
* `--user-data-dir` gives us a real Chromium we can drive over CDP without touching
|
|
10
|
+
* their everyday profile, bookmarks or windows: nothing of theirs is opened, locked
|
|
11
|
+
* or modified, and closing us never closes them.
|
|
12
|
+
*
|
|
13
|
+
* HOW
|
|
14
|
+
* Chromium exposes CDP on `--remote-debugging-port=0`, and writes the port it chose
|
|
15
|
+
* into `<profile>/DevToolsActivePort`. That file is the discovery mechanism — no
|
|
16
|
+
* port guessing, no collisions. From there everything is CDP over one WebSocket:
|
|
17
|
+
* `Target.createTarget` makes a page, `Target.attachToTarget` (flattened) gives a
|
|
18
|
+
* session for it, and the plugin's commands ride that session. Node 22 ships a global
|
|
19
|
+
* `WebSocket`, so this needs no dependency at all.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT IMPLEMENTS
|
|
22
|
+
* The same `ElectronBrowserViewHost` seam as the self-hosted and sidebar hosts, so
|
|
23
|
+
* the provider, the tools, history, the cursor and teardown rules are unchanged —
|
|
24
|
+
* only the carrier differs.
|
|
25
|
+
* @module dsh-browser/browser-electron/system-browser
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync, rmSync } from 'node:fs'
|
|
29
|
+
import { existsSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
32
|
+
import { randomUUID } from 'node:crypto'
|
|
33
|
+
import type { ElectronBrowserViewHost, ElectronViewHandle } from './provider.js'
|
|
34
|
+
|
|
35
|
+
/** Which browser the user asked for. */
|
|
36
|
+
export type BrowserChannel = 'bundled' | 'chrome' | 'edge' | 'auto'
|
|
37
|
+
|
|
38
|
+
/** A resolved browser installation. */
|
|
39
|
+
export interface DetectedBrowser {
|
|
40
|
+
/** Which product this is. */
|
|
41
|
+
readonly kind: 'chrome' | 'edge' | 'brave'
|
|
42
|
+
/** Absolute path to the executable. */
|
|
43
|
+
readonly path: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Where each Chromium product installs on Windows, plus the registry paths that
|
|
48
|
+
* survive a non-default install location. Only paths that exist are returned, so a
|
|
49
|
+
* caller can tell "not installed" from "installed elsewhere".
|
|
50
|
+
*/
|
|
51
|
+
const WINDOWS_LOCATIONS: Record<DetectedBrowser['kind'], string[]> = {
|
|
52
|
+
chrome: [
|
|
53
|
+
String.raw`C:\Program Files\Google\Chrome\Application\chrome.exe`,
|
|
54
|
+
String.raw`C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
|
|
55
|
+
],
|
|
56
|
+
edge: [
|
|
57
|
+
String.raw`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
|
|
58
|
+
String.raw`C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
|
|
59
|
+
],
|
|
60
|
+
brave: [
|
|
61
|
+
String.raw`C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
62
|
+
String.raw`C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Find an installed Chromium browser.
|
|
68
|
+
*
|
|
69
|
+
* `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
|
|
70
|
+
* is to be the browser a user actually chose rather than one Windows shipped.
|
|
71
|
+
* @param channel - the configured choice; `bundled` never resolves to a system browser.
|
|
72
|
+
* @param env - environment lookup, injected so tests need no real machine.
|
|
73
|
+
* @returns the detected browser, or undefined when the choice is unavailable.
|
|
74
|
+
*/
|
|
75
|
+
export function detectBrowser(channel: BrowserChannel, env: NodeJS.ProcessEnv = process.env): DetectedBrowser | undefined {
|
|
76
|
+
if (channel === 'bundled') return undefined
|
|
77
|
+
const order: DetectedBrowser['kind'][] = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel as DetectedBrowser['kind']]
|
|
78
|
+
for (const kind of order) {
|
|
79
|
+
const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined)
|
|
80
|
+
if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv)) return { kind, path: fromEnv }
|
|
81
|
+
for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
|
|
82
|
+
if (existsSync(candidate)) return { kind, path: candidate }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One CDP connection to a browser-level WebSocket, with flattened sessions. */
|
|
89
|
+
class CdpClient {
|
|
90
|
+
private nextId = 1
|
|
91
|
+
private readonly pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
|
92
|
+
private readonly ready: Promise<void>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param url - the browser's `webSocketDebuggerUrl`.
|
|
96
|
+
*/
|
|
97
|
+
constructor(private readonly url: string) {
|
|
98
|
+
// Node 22 provides WebSocket globally; no dependency is added for this.
|
|
99
|
+
const socket = new WebSocket(url)
|
|
100
|
+
this.socket = socket
|
|
101
|
+
this.ready = new Promise<void>((resolve, reject) => {
|
|
102
|
+
socket.addEventListener('open', () => resolve())
|
|
103
|
+
socket.addEventListener('error', () => reject(new Error('dsh-builtin-browser: could not reach the browser over CDP')))
|
|
104
|
+
})
|
|
105
|
+
socket.addEventListener('message', event => this.onMessage(String((event as MessageEvent).data)))
|
|
106
|
+
socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private readonly socket: WebSocket
|
|
110
|
+
|
|
111
|
+
/** Resolve once the socket is open. */
|
|
112
|
+
async whenReady(): Promise<void> {
|
|
113
|
+
await this.ready
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Send one CDP command, optionally on an attached session.
|
|
118
|
+
* @param method - the CDP method.
|
|
119
|
+
* @param params - its parameters.
|
|
120
|
+
* @param sessionId - the flattened session to target, when this is a page command.
|
|
121
|
+
* @returns the CDP result.
|
|
122
|
+
*/
|
|
123
|
+
async send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<unknown> {
|
|
124
|
+
await this.ready
|
|
125
|
+
const id = this.nextId++
|
|
126
|
+
const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} }
|
|
127
|
+
return await new Promise<unknown>((resolve, reject) => {
|
|
128
|
+
this.pending.set(id, { resolve, reject })
|
|
129
|
+
this.socket.send(JSON.stringify(message))
|
|
130
|
+
setTimeout(() => {
|
|
131
|
+
if (this.pending.delete(id)) reject(new Error(`dsh-builtin-browser: ${method} timed out`))
|
|
132
|
+
}, 30_000).unref?.()
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Close the socket. */
|
|
137
|
+
close(): void {
|
|
138
|
+
try { this.socket.close() } catch { /* already closed */ }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Route one CDP message to its waiter. */
|
|
142
|
+
private onMessage(raw: string): void {
|
|
143
|
+
let message: { id?: number; result?: unknown; error?: { message?: string } }
|
|
144
|
+
try { message = JSON.parse(raw) } catch { return }
|
|
145
|
+
if (message.id === undefined) return
|
|
146
|
+
const entry = this.pending.get(message.id)
|
|
147
|
+
if (entry === undefined) return
|
|
148
|
+
this.pending.delete(message.id)
|
|
149
|
+
if (message.error !== undefined) entry.reject(new Error(`dsh-builtin-browser: CDP error: ${message.error.message ?? 'unknown'}`))
|
|
150
|
+
else entry.resolve(message.result)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fail everything in flight. */
|
|
154
|
+
private fail(error: Error): void {
|
|
155
|
+
const pending = [...this.pending.values()]
|
|
156
|
+
this.pending.clear()
|
|
157
|
+
for (const entry of pending) entry.reject(error)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A system browser driven over CDP, presented as a browser view host. */
|
|
162
|
+
export class SystemBrowserViewHost implements ElectronBrowserViewHost {
|
|
163
|
+
private readonly views = new Map<string, string>()
|
|
164
|
+
private disposed = false
|
|
165
|
+
/**
|
|
166
|
+
* The browser is started on FIRST USE, never on construction.
|
|
167
|
+
*
|
|
168
|
+
* A plugin that launches Chrome the moment its host loads is a plugin that takes
|
|
169
|
+
* over the machine before anyone asked it to: simply starting DSH would spawn a
|
|
170
|
+
* browser window. Registration must therefore be inert, and the process is only
|
|
171
|
+
* spawned when a view is actually needed.
|
|
172
|
+
*/
|
|
173
|
+
private client: CdpClient | undefined
|
|
174
|
+
private child: ChildProcess | undefined
|
|
175
|
+
private starting: Promise<CdpClient> | undefined
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* @param browser - the detected installation to launch on first use.
|
|
179
|
+
* @param profileDir - a plugin-owned directory; the user's own profile is never touched.
|
|
180
|
+
* @param extraArgs - additional Chromium switches.
|
|
181
|
+
* @param ephemeralDir - a directory to delete on release, when the user has turned
|
|
182
|
+
* persistence off so no login state outlives the session.
|
|
183
|
+
*/
|
|
184
|
+
constructor(
|
|
185
|
+
private readonly browser: DetectedBrowser,
|
|
186
|
+
private readonly profileDir: string,
|
|
187
|
+
private readonly extraArgs: readonly string[] = [],
|
|
188
|
+
private readonly ephemeralDir?: string,
|
|
189
|
+
) {}
|
|
190
|
+
|
|
191
|
+
/** Which product this host would drive (for diagnostics). */
|
|
192
|
+
get kind(): DetectedBrowser['kind'] {
|
|
193
|
+
return this.browser.kind
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Whether the browser has actually been started yet (diagnostics and tests). */
|
|
197
|
+
get started(): boolean {
|
|
198
|
+
return this.child !== undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The CDP client, starting the browser on first use.
|
|
203
|
+
* @returns the connected client.
|
|
204
|
+
*/
|
|
205
|
+
private async ensureClient(): Promise<CdpClient> {
|
|
206
|
+
if (this.client !== undefined) return this.client
|
|
207
|
+
// Concurrent first calls share one startup rather than racing two browsers.
|
|
208
|
+
this.starting ??= this.start().finally(() => { this.starting = undefined })
|
|
209
|
+
return await this.starting
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Launch the browser with a private profile and connect over CDP.
|
|
214
|
+
*
|
|
215
|
+
* `--remote-debugging-port=0` plus the `DevToolsActivePort` file is the only
|
|
216
|
+
* reliable way to learn the port: a fixed port collides with whatever else the
|
|
217
|
+
* machine is running, and parsing stderr is fragile across versions.
|
|
218
|
+
* @returns the connected client.
|
|
219
|
+
*/
|
|
220
|
+
private async start(): Promise<CdpClient> {
|
|
221
|
+
const args = [
|
|
222
|
+
'--remote-debugging-port=0',
|
|
223
|
+
`--user-data-dir=${this.profileDir}`,
|
|
224
|
+
// Behave like a fresh, unattended browser: no first-run UI, no default-browser
|
|
225
|
+
// prompt, no restore bubble, and no "Chrome is being controlled" infobar.
|
|
226
|
+
'--no-first-run',
|
|
227
|
+
'--no-default-browser-check',
|
|
228
|
+
'--disable-features=Translate,MediaRouter',
|
|
229
|
+
'--disable-session-crashed-bubble',
|
|
230
|
+
'--hide-crash-restore-bubble',
|
|
231
|
+
...this.extraArgs,
|
|
232
|
+
'about:blank',
|
|
233
|
+
]
|
|
234
|
+
const child = spawn(this.browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false })
|
|
235
|
+
this.child = child
|
|
236
|
+
const portFile = join(this.profileDir, 'DevToolsActivePort')
|
|
237
|
+
const deadline = Date.now() + 30_000
|
|
238
|
+
for (;;) {
|
|
239
|
+
if (Date.now() > deadline) { child.kill(); this.child = undefined; break }
|
|
240
|
+
if (child.exitCode !== null) { this.child = undefined; break }
|
|
241
|
+
if (existsSync(portFile)) {
|
|
242
|
+
try {
|
|
243
|
+
const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? ''
|
|
244
|
+
if (port !== '') {
|
|
245
|
+
const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json() as { webSocketDebuggerUrl?: string }
|
|
246
|
+
if (typeof version.webSocketDebuggerUrl === 'string') {
|
|
247
|
+
const client = new CdpClient(version.webSocketDebuggerUrl)
|
|
248
|
+
await client.whenReady()
|
|
249
|
+
this.client = client
|
|
250
|
+
return client
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// The file can exist a moment before the port answers; poll again.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`dsh-builtin-browser: ${this.browser.kind} did not expose CDP within 30s (${this.browser.path})`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether this host can back views.
|
|
264
|
+
*
|
|
265
|
+
* True while the host is usable — including before the browser has been started,
|
|
266
|
+
* since the first command is what starts it. Reporting "unavailable" here would
|
|
267
|
+
* make the plugin fall back for no reason.
|
|
268
|
+
*/
|
|
269
|
+
available(): boolean {
|
|
270
|
+
return !this.disposed
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
createView(): ElectronViewHandle {
|
|
274
|
+
const viewId = randomUUID()
|
|
275
|
+
return {
|
|
276
|
+
id: viewId,
|
|
277
|
+
sendCommand: async (method: string, params?: Record<string, unknown>) => {
|
|
278
|
+
const client = await this.ensureClient()
|
|
279
|
+
const session = this.views.get(viewId) ?? await this.ensureSession(viewId)
|
|
280
|
+
const result = await client.send(method, params, session)
|
|
281
|
+
return (result ?? {}) as Record<string, unknown>
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
destroyView(handle: ElectronViewHandle): void {
|
|
287
|
+
const session = this.views.get(handle.id)
|
|
288
|
+
this.views.delete(handle.id)
|
|
289
|
+
if (session === undefined) return
|
|
290
|
+
// A session id is not a target id: closing the page needs the target the session
|
|
291
|
+
// was attached to, which is why they are tracked separately.
|
|
292
|
+
const targetId = this.sessions.get(session)
|
|
293
|
+
this.sessions.delete(session)
|
|
294
|
+
// Nothing to close if the browser was never started.
|
|
295
|
+
if (targetId !== undefined && this.client !== undefined) {
|
|
296
|
+
// Best-effort: a page that is already gone is not an error.
|
|
297
|
+
void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Nothing to show: the browser owns its own windows. */
|
|
302
|
+
showView(): void {}
|
|
303
|
+
|
|
304
|
+
/** Same as {@link showView}. */
|
|
305
|
+
async presentView(): Promise<void> {}
|
|
306
|
+
|
|
307
|
+
/** The browser owns window grouping. */
|
|
308
|
+
groupView(): void {}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Bring the browser's window forward.
|
|
312
|
+
*
|
|
313
|
+
* The browser is a separate application, so there is no view to raise; focusing
|
|
314
|
+
* the process is the closest honest equivalent, and failing to do so (a minimized
|
|
315
|
+
* window, a locked session) is never worth an error.
|
|
316
|
+
*/
|
|
317
|
+
async focus(): Promise<void> {
|
|
318
|
+
try { this.child?.kill('SIGCONT') } catch { /* not supported on Windows; harmless */ }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The browser reports its own window lifecycle. */
|
|
322
|
+
onUserAction(): void {}
|
|
323
|
+
|
|
324
|
+
/** The browser reports its own window lifecycle. */
|
|
325
|
+
onViewClosed(): void {}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Close the browser we launched, if we ever launched one.
|
|
329
|
+
*
|
|
330
|
+
* The user's own windows belong to a different process and are never touched. A
|
|
331
|
+
* host whose browser was never started (the common case when nobody used the
|
|
332
|
+
* browser) simply tears down its bookkeeping.
|
|
333
|
+
*/
|
|
334
|
+
dispose(): void {
|
|
335
|
+
if (this.disposed) return
|
|
336
|
+
this.disposed = true
|
|
337
|
+
this.views.clear()
|
|
338
|
+
this.sessions.clear()
|
|
339
|
+
this.client?.close()
|
|
340
|
+
try { this.child?.kill() } catch { /* already gone */ }
|
|
341
|
+
const ephemeral = this.ephemeralDir
|
|
342
|
+
if (ephemeral !== undefined && this.child !== undefined) {
|
|
343
|
+
// The browser flushes its profile while shutting down, so deleting later avoids
|
|
344
|
+
// racing it. Leftovers are only a stray temp directory, never a live credential.
|
|
345
|
+
setTimeout(() => { try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ } }, 3_000).unref?.()
|
|
346
|
+
} else if (ephemeral !== undefined) {
|
|
347
|
+
// Never started, so nothing can be holding it open.
|
|
348
|
+
try { rmSync(ephemeral, { recursive: true, force: true }) } catch { /* best effort */ }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The flattened session for a view, creating its page on first use. */
|
|
353
|
+
private async ensureSession(viewId: string): Promise<string> {
|
|
354
|
+
const existing = this.views.get(viewId)
|
|
355
|
+
if (existing !== undefined) return existing
|
|
356
|
+
const client = await this.ensureClient()
|
|
357
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' }) as { targetId?: string }
|
|
358
|
+
const targetId = created?.targetId
|
|
359
|
+
if (typeof targetId !== 'string') throw new Error('dsh-builtin-browser: the browser did not create a page')
|
|
360
|
+
const attached = await client.send('Target.attachToTarget', { targetId, flatten: true }) as { sessionId?: string }
|
|
361
|
+
const sessionId = attached?.sessionId
|
|
362
|
+
if (typeof sessionId !== 'string') throw new Error('dsh-builtin-browser: the browser did not attach to the new page')
|
|
363
|
+
// The session id is what page commands ride; the target id is what closes it.
|
|
364
|
+
this.sessions.set(sessionId, targetId)
|
|
365
|
+
this.views.set(viewId, sessionId)
|
|
366
|
+
return sessionId
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** sessionId -> targetId, so a destroyed view can close the right page. */
|
|
370
|
+
private readonly sessions = new Map<string, string>()
|
|
371
|
+
}
|
|
@@ -22,15 +22,46 @@ export type CursorAction = 'move' | 'click'
|
|
|
22
22
|
/** Element id of the overlay root, stable so repeat calls update one node. */
|
|
23
23
|
const CURSOR_ID = '__dsh_agent_cursor__'
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Where the overlay was last placed, so an unchanged position is not repainted.
|
|
27
|
+
*
|
|
28
|
+
* A single tool call issues several CDP commands (locate, click, cursor), and the
|
|
29
|
+
* cursor is repainted for each one that moves it. Repainting to the same pixel is a
|
|
30
|
+
* wasted round-trip *and* a visible flicker: the transition restarts from the same
|
|
31
|
+
* place, which reads as a stutter rather than as movement.
|
|
32
|
+
*/
|
|
33
|
+
const lastPlaced = new WeakMap<object, { x: number; y: number; action: CursorAction }>()
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a paint at this position would change anything on screen.
|
|
37
|
+
* @param handle - the view the overlay belongs to.
|
|
38
|
+
* @param x - viewport x in CSS pixels.
|
|
39
|
+
* @param y - viewport y in CSS pixels.
|
|
40
|
+
* @param action - the action being reported.
|
|
41
|
+
* @param force - paint even when unchanged (a click ripple must always fire).
|
|
42
|
+
* @returns true when the overlay should be painted.
|
|
43
|
+
*/
|
|
44
|
+
export function cursorNeedsPaint<TView>(handle: TView, x: number, y: number, action: CursorAction, force = false): boolean {
|
|
45
|
+
if (force) return true
|
|
46
|
+
const key = handle as unknown as object
|
|
47
|
+
if (key === null || typeof key !== 'object') return true
|
|
48
|
+
const previous = lastPlaced.get(key)
|
|
49
|
+
return previous === undefined || previous.x !== x || previous.y !== y || previous.action !== action
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
/**
|
|
26
53
|
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
27
54
|
* first call creates the nodes, later calls only move them (and pulse).
|
|
28
55
|
* @param x - viewport x in CSS pixels.
|
|
29
56
|
* @param y - viewport y in CSS pixels.
|
|
30
57
|
* @param action - whether to pulse a click ripple at the point.
|
|
58
|
+
* @param label - optional short description of the operation, shown beside the
|
|
59
|
+
* pointer. The overlay is the only "the agent has taken over this tab" signal, so
|
|
60
|
+
* saying *what* it is doing is what turns it from a decoration into an account of
|
|
61
|
+
* the agent's actions that a human can follow without reading the tool log.
|
|
31
62
|
* @returns a self-contained expression that evaluates to `true`.
|
|
32
63
|
*/
|
|
33
|
-
export function cursorScript(x: number, y: number, action: CursorAction): string {
|
|
64
|
+
export function cursorScript(x: number, y: number, action: CursorAction, label?: string): string {
|
|
34
65
|
return `(() => {
|
|
35
66
|
const id = ${JSON.stringify(CURSOR_ID)}
|
|
36
67
|
let root = document.getElementById(id)
|
|
@@ -38,25 +69,56 @@ export function cursorScript(x: number, y: number, action: CursorAction): string
|
|
|
38
69
|
root = document.createElement('div')
|
|
39
70
|
root.id = id
|
|
40
71
|
root.setAttribute('aria-hidden', 'true')
|
|
41
|
-
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform
|
|
72
|
+
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 190ms cubic-bezier(.22,.85,.24,1);will-change:transform'
|
|
42
73
|
const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
|
|
43
74
|
+ '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
44
|
-
const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
45
|
-
|
|
75
|
+
const ripple = '<span data-dsh-role="ripple" style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
76
|
+
// The bubble sits below-right of the pointer tip and is capped in width so a long
|
|
77
|
+
// label cannot cover the page it is describing.
|
|
78
|
+
const bubble = '<span data-dsh-role="bubble" style="position:absolute;left:18px;top:16px;max-width:280px;padding:3px 8px;border-radius:5px;background:rgba(17,17,17,.92);color:#fff;font:12px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;opacity:0;box-shadow:0 1px 3px rgba(0,0,0,.4)"></span>'
|
|
79
|
+
root.innerHTML = svg + ripple + bubble
|
|
46
80
|
;(document.body || document.documentElement).appendChild(root)
|
|
47
81
|
}
|
|
48
82
|
root.style.transform = 'translate(${x}px, ${y}px)'
|
|
49
|
-
${action === 'click' ? `const ripple = root.
|
|
83
|
+
${action === 'click' ? `const ripple = root.querySelector('[data-dsh-role="ripple"]')
|
|
50
84
|
if (ripple && typeof ripple.animate === 'function') {
|
|
51
85
|
ripple.animate(
|
|
52
86
|
[{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
|
|
53
87
|
{ duration: 550, easing: 'ease-out' },
|
|
54
88
|
)
|
|
55
89
|
}` : ''}
|
|
90
|
+
${label !== undefined && label !== '' ? `const bubble = root.querySelector('[data-dsh-role="bubble"]')
|
|
91
|
+
if (bubble) {
|
|
92
|
+
bubble.textContent = ${JSON.stringify(label)}
|
|
93
|
+
if (typeof bubble.animate === 'function') {
|
|
94
|
+
// Fade in, hold, fade out: the label is a narration of the action, not a
|
|
95
|
+
// permanent caption, so a stale one never lingers over later work.
|
|
96
|
+
bubble.animate(
|
|
97
|
+
[{ opacity: 0, transform: 'translateY(-3px)' }, { opacity: 1, transform: 'translateY(0)' }, { opacity: 1, offset: 0.6 }, { opacity: 0 }],
|
|
98
|
+
{ duration: 2200, easing: 'ease-out', fill: 'forwards' },
|
|
99
|
+
)
|
|
100
|
+
} else {
|
|
101
|
+
bubble.style.opacity = '1'
|
|
102
|
+
}
|
|
103
|
+
}` : ''}
|
|
56
104
|
return true
|
|
57
105
|
})()`
|
|
58
106
|
}
|
|
59
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Forget the remembered position for a view.
|
|
110
|
+
*
|
|
111
|
+
* Called when the document is replaced: the overlay lived in the old document and
|
|
112
|
+
* went with it, so the cache would otherwise claim the pointer is already where it
|
|
113
|
+
* needs to be and skip every later paint — the pointer would simply never return
|
|
114
|
+
* after a navigation.
|
|
115
|
+
* @param handle - the view whose cache should be dropped.
|
|
116
|
+
*/
|
|
117
|
+
export function forgetCursor<TView>(handle: TView): void {
|
|
118
|
+
const key = handle as unknown as object
|
|
119
|
+
if (key !== null && typeof key === 'object') lastPlaced.delete(key)
|
|
120
|
+
}
|
|
121
|
+
|
|
60
122
|
/**
|
|
61
123
|
* Draw (or move) the pointer overlay on one view's page.
|
|
62
124
|
*
|
|
@@ -69,6 +131,7 @@ export function cursorScript(x: number, y: number, action: CursorAction): string
|
|
|
69
131
|
* @param y - viewport y in CSS pixels.
|
|
70
132
|
* @param action - whether to pulse a click ripple.
|
|
71
133
|
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
134
|
+
* @param label - optional short description of the operation, shown beside the pointer.
|
|
72
135
|
*/
|
|
73
136
|
export function paintCursor<TView>(
|
|
74
137
|
handle: TView,
|
|
@@ -76,7 +139,14 @@ export function paintCursor<TView>(
|
|
|
76
139
|
y: number,
|
|
77
140
|
action: CursorAction,
|
|
78
141
|
evaluate: (handle: TView, expression: string) => Promise<unknown>,
|
|
142
|
+
label?: string,
|
|
143
|
+
force = false,
|
|
79
144
|
): void {
|
|
80
145
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
81
|
-
|
|
146
|
+
const roundedX = Math.round(x)
|
|
147
|
+
const roundedY = Math.round(y)
|
|
148
|
+
if (!cursorNeedsPaint(handle, roundedX, roundedY, action, force)) return
|
|
149
|
+
const key = handle as unknown as object
|
|
150
|
+
if (key !== null && typeof key === 'object') lastPlaced.set(key, { x: roundedX, y: roundedY, action })
|
|
151
|
+
void evaluate(handle, cursorScript(roundedX, roundedY, action, label)).catch(() => {})
|
|
82
152
|
}
|
|
@@ -161,15 +161,26 @@ function parseFillValue(v: string | undefined): string | number | boolean {
|
|
|
161
161
|
return v ?? ''
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Format a snapshot element list for the model.
|
|
166
|
+
*
|
|
167
|
+
* Coordinates are opt-in. They are only meaningful to a caller that intends to
|
|
168
|
+
* click a pixel position, which means a vision pass; for the far more common case
|
|
169
|
+
* of locating an element by role/label and passing a semantic target, they are the
|
|
170
|
+
* bulkiest and least useful part of every line. Omitting them keeps the listing
|
|
171
|
+
* readable — which is exactly what a model without image input has to work from.
|
|
172
|
+
* @param snapshot - the snapshot payload.
|
|
173
|
+
* @param options - `coords: true` to include each element's viewport position.
|
|
174
|
+
*/
|
|
165
175
|
function formatSnapshot(snapshot: {
|
|
166
176
|
url: string
|
|
167
177
|
title?: string
|
|
168
178
|
elements: readonly { ref: number; kind: string; label: string; x: number; y: number; frame?: boolean }[]
|
|
169
179
|
truncated?: boolean
|
|
170
180
|
challenge?: { blocked: boolean; kind?: string; reason?: string }
|
|
171
|
-
}): string {
|
|
172
|
-
const
|
|
181
|
+
}, options: { coords?: boolean } = {}): string {
|
|
182
|
+
const showCoords = options.coords === true
|
|
183
|
+
const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`)
|
|
173
184
|
const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`
|
|
174
185
|
const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)'
|
|
175
186
|
const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : ''
|
|
@@ -241,7 +252,10 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
241
252
|
},
|
|
242
253
|
},
|
|
243
254
|
},
|
|
244
|
-
|
|
255
|
+
// browser_open is an overview of what just loaded, not a locator: it has no
|
|
256
|
+
// `coords` parameter, so coordinates stay off here (browser_snapshot owns
|
|
257
|
+
// that switch for callers that actually target pixels).
|
|
258
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: (args as { coords?: boolean }).coords === true }) }],
|
|
245
259
|
},
|
|
246
260
|
timeoutMs,
|
|
247
261
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
@@ -267,8 +281,10 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
267
281
|
|
|
268
282
|
ctx.tools.register(defineTool({
|
|
269
283
|
name: 'browser_snapshot',
|
|
270
|
-
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite.
|
|
271
|
-
parameters: {
|
|
284
|
+
description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. This is the primary way to understand a page, and it needs no image input — every listed element can afterwards be addressed by a semantic target (browser_click, browser_type, browser_check, …) rather than by coordinates.',
|
|
285
|
+
parameters: {
|
|
286
|
+
coords: { type: 'boolean', description: 'Also print each element\'s viewport coordinates. Off by default; only useful when a vision pass is going to click a pixel position.' },
|
|
287
|
+
},
|
|
272
288
|
output: {
|
|
273
289
|
schema: {
|
|
274
290
|
type: 'object',
|
|
@@ -304,7 +320,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
304
320
|
},
|
|
305
321
|
},
|
|
306
322
|
},
|
|
307
|
-
render: (
|
|
323
|
+
render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
|
|
308
324
|
},
|
|
309
325
|
timeoutMs,
|
|
310
326
|
isConcurrencySafe: () => true,
|
|
@@ -329,6 +345,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
329
345
|
parameters: {
|
|
330
346
|
includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
|
|
331
347
|
maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
|
|
348
|
+
coords: { type: 'boolean', description: 'Include each element\'s viewport coordinates. Off by default: a semantic target needs no coordinates, and they are pure noise unless a vision pass is going to click a pixel position.' },
|
|
332
349
|
},
|
|
333
350
|
output: {
|
|
334
351
|
schema: {
|
|
@@ -361,14 +378,24 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
361
378
|
},
|
|
362
379
|
},
|
|
363
380
|
},
|
|
364
|
-
render: (
|
|
381
|
+
render: (args, value) => {
|
|
365
382
|
const nodes = value.nodes as Array<{ ref: number; role: string; name: string; value?: string | null; states: string[]; depth: number; tag: string; x: number; y: number; frame?: boolean }>
|
|
383
|
+
// Built for reading, not for imaging: containment is shown as indentation
|
|
384
|
+
// (the depth was already collected and simply thrown away), an empty state
|
|
385
|
+
// list adds nothing, and coordinates appear only when asked for — a model
|
|
386
|
+
// working from the DOM never needs them, and they are the bulkiest part of
|
|
387
|
+
// every line.
|
|
388
|
+
const showCoords = args.coords === true
|
|
366
389
|
const lines = nodes.map(n => {
|
|
367
390
|
const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : ''
|
|
368
|
-
|
|
391
|
+
const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)))
|
|
392
|
+
const coordsPart = showCoords ? ` (${n.x},${n.y})` : ''
|
|
393
|
+
const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : ''
|
|
394
|
+
return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`
|
|
369
395
|
})
|
|
370
396
|
const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`
|
|
371
|
-
|
|
397
|
+
const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)'
|
|
398
|
+
return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }]
|
|
372
399
|
},
|
|
373
400
|
},
|
|
374
401
|
timeoutMs,
|
|
@@ -601,7 +628,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
601
628
|
|
|
602
629
|
ctx.tools.register(defineTool({
|
|
603
630
|
name: 'browser_click',
|
|
604
|
-
description: 'Click in the shared browser.
|
|
631
|
+
description: 'Click in the shared browser. Prefer the semantic target: pass target {by: css|text|xpath, value, index?} and the element is located from the DOM, scrolled into view and clicked at its centre — this needs no image input and is the only form that works without vision. The alternative is viewport coordinates (x/y), which are meaningful only after a vision model has located the element on a browser_screenshot; coordinate clicks are refused outright under the non-visual strategy. Provide exactly one of target or x/y.',
|
|
605
632
|
parameters: {
|
|
606
633
|
target: {
|
|
607
634
|
type: 'object',
|
|
@@ -613,8 +640,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
613
640
|
},
|
|
614
641
|
description: 'Locate the element semantically and click it (css/text/xpath).',
|
|
615
642
|
},
|
|
616
|
-
x: { type: 'number', description: 'Viewport x coordinate (CSS px),
|
|
617
|
-
y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
|
|
643
|
+
x: { type: 'number', description: 'Viewport x coordinate (CSS px), taken from a browser_screenshot read by a vision model. Prefer a semantic target — it works without image input.' },
|
|
644
|
+
y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
|
|
618
645
|
},
|
|
619
646
|
output: {
|
|
620
647
|
schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
|
|
@@ -1089,7 +1116,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1089
1116
|
|
|
1090
1117
|
ctx.tools.register(defineTool({
|
|
1091
1118
|
name: 'browser_screenshot',
|
|
1092
|
-
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional).
|
|
1119
|
+
description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). This is for models that can read images: layout checks, charts, designs, CAPTCHAs, or locating an element by eye before clicking its coordinates. A model without image input gains nothing from it — browser_snapshot, browser_a11y and browser_content carry the same page as text, and browser_scrape extracts structured data. Supports full-page capture, save-to-file, JPEG encoding, and downscaling (maxWidth/maxHeight) to cut vision-tool token cost. JPEG is only available on the self-hosted native path; the desktop-shell path returns PNG.',
|
|
1093
1120
|
parameters: {
|
|
1094
1121
|
fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
|
|
1095
1122
|
savePath: { type: 'string', description: 'Absolute file path to also save the image to (e.g. for read_image vision location). Must resolve inside the configured downloadDir (default: the system Downloads folder, localized names such as ~/下载 included); an existing file is never overwritten.' },
|
|
@@ -1338,6 +1365,8 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1338
1365
|
parameters: {
|
|
1339
1366
|
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1340
1367
|
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1368
|
+
query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
|
|
1369
|
+
session: { type: 'string', description: 'Only visits recorded by this task/session label — use it to separate your own pages from another session\'s or the human\'s.' },
|
|
1341
1370
|
},
|
|
1342
1371
|
output: {
|
|
1343
1372
|
schema: {
|
|
@@ -1384,7 +1413,14 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1384
1413
|
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
|
|
1385
1414
|
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
|
|
1386
1415
|
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
|
|
1387
|
-
const
|
|
1416
|
+
const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined
|
|
1417
|
+
const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined
|
|
1418
|
+
const entries = browser.visited({
|
|
1419
|
+
limit,
|
|
1420
|
+
...domain !== undefined ? { domain } : {},
|
|
1421
|
+
...query !== undefined ? { query } : {},
|
|
1422
|
+
...session !== undefined ? { session } : {},
|
|
1423
|
+
})
|
|
1388
1424
|
return {
|
|
1389
1425
|
count: entries.length,
|
|
1390
1426
|
entries: entries.map(entry => ({
|