dsh-builtin-browser 0.2.0 → 0.3.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.
Files changed (34) hide show
  1. package/README.en.md +46 -1
  2. package/README.md +58 -7
  3. package/client.js +44 -8
  4. package/desktop-bridge/install.mjs +98 -0
  5. package/desktop-bridge/plugin-browser-bridge.js +385 -0
  6. package/lib/browser-electron/bridge-connection.d.ts +58 -0
  7. package/lib/browser-electron/bridge-connection.js +133 -0
  8. package/lib/browser-electron/desktop-bridge-host.d.ts +124 -0
  9. package/lib/browser-electron/desktop-bridge-host.js +297 -0
  10. package/lib/browser-electron/entry.js +82 -13
  11. package/lib/browser-electron/history-store.d.ts +5 -1
  12. package/lib/browser-electron/history-store.js +12 -1
  13. package/lib/browser-electron/provider.d.ts +42 -1
  14. package/lib/browser-electron/provider.js +96 -24
  15. package/lib/browser-electron/remote-host.d.ts +9 -1
  16. package/lib/browser-electron/remote-host.js +17 -3
  17. package/lib/browser-electron/settings-store.d.ts +5 -0
  18. package/lib/browser-electron/settings-store.js +24 -1
  19. package/lib/browser-electron/system-browser.d.ts +100 -0
  20. package/lib/browser-electron/system-browser.js +322 -0
  21. package/lib/browser-electron/virtual-cursor.d.ts +27 -2
  22. package/lib/browser-electron/virtual-cursor.js +77 -7
  23. package/lib/tool-browser/index.js +51 -15
  24. package/package.json +2 -1
  25. package/src/browser-electron/bridge-connection.ts +145 -0
  26. package/src/browser-electron/desktop-bridge-host.ts +305 -0
  27. package/src/browser-electron/entry.ts +84 -13
  28. package/src/browser-electron/history-store.ts +11 -2
  29. package/src/browser-electron/provider.ts +124 -25
  30. package/src/browser-electron/remote-host.ts +17 -2
  31. package/src/browser-electron/settings-store.ts +30 -1
  32. package/src/browser-electron/system-browser.ts +318 -0
  33. package/src/browser-electron/virtual-cursor.ts +76 -6
  34. package/src/tool-browser/index.ts +51 -15
@@ -0,0 +1,322 @@
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
+ import { readFileSync, rmSync } from 'node:fs';
28
+ import { existsSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { spawn } from 'node:child_process';
31
+ import { randomUUID } from 'node:crypto';
32
+ /**
33
+ * Where each Chromium product installs on Windows, plus the registry paths that
34
+ * survive a non-default install location. Only paths that exist are returned, so a
35
+ * caller can tell "not installed" from "installed elsewhere".
36
+ */
37
+ const WINDOWS_LOCATIONS = {
38
+ chrome: [
39
+ String.raw `C:\Program Files\Google\Chrome\Application\chrome.exe`,
40
+ String.raw `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`,
41
+ ],
42
+ edge: [
43
+ String.raw `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`,
44
+ String.raw `C:\Program Files\Microsoft\Edge\Application\msedge.exe`,
45
+ ],
46
+ brave: [
47
+ String.raw `C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe`,
48
+ String.raw `C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe`,
49
+ ],
50
+ };
51
+ /**
52
+ * Find an installed Chromium browser.
53
+ *
54
+ * `auto` prefers Chrome, then Edge, then Brave — the order reflects how likely each
55
+ * is to be the browser a user actually chose rather than one Windows shipped.
56
+ * @param channel - the configured choice; `bundled` never resolves to a system browser.
57
+ * @param env - environment lookup, injected so tests need no real machine.
58
+ * @returns the detected browser, or undefined when the choice is unavailable.
59
+ */
60
+ export function detectBrowser(channel, env = process.env) {
61
+ if (channel === 'bundled')
62
+ return undefined;
63
+ const order = channel === 'auto' ? ['chrome', 'edge', 'brave'] : [channel];
64
+ for (const kind of order) {
65
+ const fromEnv = (kind === 'chrome' ? env.DSH_BROWSER_CHROME_PATH : kind === 'edge' ? env.DSH_BROWSER_EDGE_PATH : undefined);
66
+ if (fromEnv !== undefined && fromEnv !== '' && existsSync(fromEnv))
67
+ return { kind, path: fromEnv };
68
+ for (const candidate of WINDOWS_LOCATIONS[kind] ?? []) {
69
+ if (existsSync(candidate))
70
+ return { kind, path: candidate };
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+ /** One CDP connection to a browser-level WebSocket, with flattened sessions. */
76
+ class CdpClient {
77
+ url;
78
+ nextId = 1;
79
+ pending = new Map();
80
+ ready;
81
+ /**
82
+ * @param url - the browser's `webSocketDebuggerUrl`.
83
+ */
84
+ constructor(url) {
85
+ this.url = url;
86
+ // Node 22 provides WebSocket globally; no dependency is added for this.
87
+ const socket = new WebSocket(url);
88
+ this.socket = socket;
89
+ this.ready = new Promise((resolve, reject) => {
90
+ socket.addEventListener('open', () => resolve());
91
+ socket.addEventListener('error', () => reject(new Error('dsh-builtin-browser: could not reach the browser over CDP')));
92
+ });
93
+ socket.addEventListener('message', event => this.onMessage(String(event.data)));
94
+ socket.addEventListener('close', () => this.fail(new Error('dsh-builtin-browser: the browser closed the CDP connection')));
95
+ }
96
+ socket;
97
+ /** Resolve once the socket is open. */
98
+ async whenReady() {
99
+ await this.ready;
100
+ }
101
+ /**
102
+ * Send one CDP command, optionally on an attached session.
103
+ * @param method - the CDP method.
104
+ * @param params - its parameters.
105
+ * @param sessionId - the flattened session to target, when this is a page command.
106
+ * @returns the CDP result.
107
+ */
108
+ async send(method, params, sessionId) {
109
+ await this.ready;
110
+ const id = this.nextId++;
111
+ const message = { id, method, ...params !== undefined ? { params } : {}, ...sessionId !== undefined ? { sessionId } : {} };
112
+ return await new Promise((resolve, reject) => {
113
+ this.pending.set(id, { resolve, reject });
114
+ this.socket.send(JSON.stringify(message));
115
+ setTimeout(() => {
116
+ if (this.pending.delete(id))
117
+ reject(new Error(`dsh-builtin-browser: ${method} timed out`));
118
+ }, 30_000).unref?.();
119
+ });
120
+ }
121
+ /** Close the socket. */
122
+ close() {
123
+ try {
124
+ this.socket.close();
125
+ }
126
+ catch { /* already closed */ }
127
+ }
128
+ /** Route one CDP message to its waiter. */
129
+ onMessage(raw) {
130
+ let message;
131
+ try {
132
+ message = JSON.parse(raw);
133
+ }
134
+ catch {
135
+ return;
136
+ }
137
+ if (message.id === undefined)
138
+ return;
139
+ const entry = this.pending.get(message.id);
140
+ if (entry === undefined)
141
+ return;
142
+ this.pending.delete(message.id);
143
+ if (message.error !== undefined)
144
+ entry.reject(new Error(`dsh-builtin-browser: CDP error: ${message.error.message ?? 'unknown'}`));
145
+ else
146
+ entry.resolve(message.result);
147
+ }
148
+ /** Fail everything in flight. */
149
+ fail(error) {
150
+ const pending = [...this.pending.values()];
151
+ this.pending.clear();
152
+ for (const entry of pending)
153
+ entry.reject(error);
154
+ }
155
+ }
156
+ /** A system browser driven over CDP, presented as a browser view host. */
157
+ export class SystemBrowserViewHost {
158
+ child;
159
+ client;
160
+ kind;
161
+ ephemeralDir;
162
+ views = new Map();
163
+ disposed = false;
164
+ /**
165
+ * @param child - the launched browser process.
166
+ * @param client - the CDP connection to it.
167
+ * @param kind - which product this is (for diagnostics).
168
+ * @param ephemeralDir - a throwaway profile to remove on release, when the user
169
+ * has turned persistence off and no login state should outlive the session.
170
+ */
171
+ constructor(child, client, kind, ephemeralDir) {
172
+ this.child = child;
173
+ this.client = client;
174
+ this.kind = kind;
175
+ this.ephemeralDir = ephemeralDir;
176
+ }
177
+ /**
178
+ * @param browser - the detected installation to launch.
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 when the browser is released, used
182
+ * when persistence is switched off so no login state outlives the session.
183
+ * @returns the host, or undefined when the browser refuses to come up.
184
+ */
185
+ static async launch(browser, profileDir, extraArgs = [], ephemeralDir) {
186
+ const args = [
187
+ '--remote-debugging-port=0',
188
+ `--user-data-dir=${profileDir}`,
189
+ // Behave like a fresh, unattended browser: no first-run UI, no default-browser
190
+ // prompt, no restore bubble, and no "Chrome is being controlled" infobar.
191
+ '--no-first-run',
192
+ '--no-default-browser-check',
193
+ '--disable-features=Translate,MediaRouter',
194
+ '--disable-session-crashed-bubble',
195
+ '--hide-crash-restore-bubble',
196
+ ...extraArgs,
197
+ 'about:blank',
198
+ ];
199
+ const child = spawn(browser.path, args, { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: false });
200
+ const portFile = join(profileDir, 'DevToolsActivePort');
201
+ const deadline = Date.now() + 30_000;
202
+ for (;;) {
203
+ if (Date.now() > deadline) {
204
+ child.kill();
205
+ return undefined;
206
+ }
207
+ if (child.exitCode !== null)
208
+ return undefined;
209
+ if (existsSync(portFile)) {
210
+ try {
211
+ const port = readFileSync(portFile, 'utf8').split('\n')[0]?.trim() ?? '';
212
+ if (port !== '') {
213
+ const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json();
214
+ if (typeof version.webSocketDebuggerUrl === 'string') {
215
+ const client = new CdpClient(version.webSocketDebuggerUrl);
216
+ await client.whenReady();
217
+ return new SystemBrowserViewHost(child, client, browser.kind, ephemeralDir);
218
+ }
219
+ }
220
+ }
221
+ catch {
222
+ // The file can exist a moment before the port answers; poll again.
223
+ }
224
+ }
225
+ await new Promise(resolve => setTimeout(resolve, 250));
226
+ }
227
+ }
228
+ /** Whether this host can back views: the CDP connection is live. */
229
+ available() {
230
+ return !this.disposed;
231
+ }
232
+ createView() {
233
+ const viewId = randomUUID();
234
+ return {
235
+ id: viewId,
236
+ sendCommand: async (method, params) => {
237
+ const session = this.views.get(viewId) ?? await this.ensureSession(viewId);
238
+ const result = await this.client.send(method, params, session);
239
+ return (result ?? {});
240
+ },
241
+ };
242
+ }
243
+ destroyView(handle) {
244
+ const session = this.views.get(handle.id);
245
+ this.views.delete(handle.id);
246
+ if (session === undefined)
247
+ return;
248
+ // A session id is not a target id: closing the page needs the target the session
249
+ // was attached to, which is why they are tracked separately.
250
+ const targetId = this.sessions.get(session);
251
+ this.sessions.delete(session);
252
+ if (targetId !== undefined) {
253
+ // Best-effort: a page that is already gone is not an error.
254
+ void this.client.send('Target.closeTarget', { targetId }).catch(() => undefined);
255
+ }
256
+ }
257
+ /** Nothing to show: the browser owns its own windows. */
258
+ showView() { }
259
+ /** Same as {@link showView}. */
260
+ async presentView() { }
261
+ /** The browser owns window grouping. */
262
+ groupView() { }
263
+ /**
264
+ * Bring the browser's window forward.
265
+ *
266
+ * The browser is a separate application, so there is no view to raise; focusing
267
+ * the process is the closest honest equivalent, and failing to do so (a minimized
268
+ * window, a locked session) is never worth an error.
269
+ */
270
+ async focus() {
271
+ try {
272
+ this.child.kill('SIGCONT');
273
+ }
274
+ catch { /* not supported on Windows; harmless */ }
275
+ }
276
+ /** The browser reports its own window lifecycle. */
277
+ onUserAction() { }
278
+ /** The browser reports its own window lifecycle. */
279
+ onViewClosed() { }
280
+ /** Close the browser we launched. The user's own windows are a different process. */
281
+ dispose() {
282
+ if (this.disposed)
283
+ return;
284
+ this.disposed = true;
285
+ this.views.clear();
286
+ this.sessions.clear();
287
+ this.client.close();
288
+ try {
289
+ this.child.kill();
290
+ }
291
+ catch { /* already gone */ }
292
+ const ephemeral = this.ephemeralDir;
293
+ if (ephemeral !== undefined) {
294
+ // The browser flushes its profile while shutting down, so deleting later avoids
295
+ // racing it. Leftovers are only a stray temp directory, never a live credential.
296
+ setTimeout(() => { try {
297
+ rmSync(ephemeral, { recursive: true, force: true });
298
+ }
299
+ catch { /* best effort */ } }, 3_000).unref?.();
300
+ }
301
+ }
302
+ /** The flattened session for a view, creating its page on first use. */
303
+ async ensureSession(viewId) {
304
+ const existing = this.views.get(viewId);
305
+ if (existing !== undefined)
306
+ return existing;
307
+ const created = await this.client.send('Target.createTarget', { url: 'about:blank' });
308
+ const targetId = created?.targetId;
309
+ if (typeof targetId !== 'string')
310
+ throw new Error('dsh-builtin-browser: the browser did not create a page');
311
+ const attached = await this.client.send('Target.attachToTarget', { targetId, flatten: true });
312
+ const sessionId = attached?.sessionId;
313
+ if (typeof sessionId !== 'string')
314
+ throw new Error('dsh-builtin-browser: the browser did not attach to the new page');
315
+ // The session id is what page commands ride; the target id is what closes it.
316
+ this.sessions.set(sessionId, targetId);
317
+ this.views.set(viewId, sessionId);
318
+ return sessionId;
319
+ }
320
+ /** sessionId -> targetId, so a destroyed view can close the right page. */
321
+ sessions = new Map();
322
+ }
@@ -17,15 +17,39 @@
17
17
  */
18
18
  /** What the pointer is doing at the reported point. */
19
19
  export type CursorAction = 'move' | 'click';
20
+ /**
21
+ * Whether a paint at this position would change anything on screen.
22
+ * @param handle - the view the overlay belongs to.
23
+ * @param x - viewport x in CSS pixels.
24
+ * @param y - viewport y in CSS pixels.
25
+ * @param action - the action being reported.
26
+ * @param force - paint even when unchanged (a click ripple must always fire).
27
+ * @returns true when the overlay should be painted.
28
+ */
29
+ export declare function cursorNeedsPaint<TView>(handle: TView, x: number, y: number, action: CursorAction, force?: boolean): boolean;
20
30
  /**
21
31
  * The in-page expression that materializes or moves the overlay. Idempotent: the
22
32
  * first call creates the nodes, later calls only move them (and pulse).
23
33
  * @param x - viewport x in CSS pixels.
24
34
  * @param y - viewport y in CSS pixels.
25
35
  * @param action - whether to pulse a click ripple at the point.
36
+ * @param label - optional short description of the operation, shown beside the
37
+ * pointer. The overlay is the only "the agent has taken over this tab" signal, so
38
+ * saying *what* it is doing is what turns it from a decoration into an account of
39
+ * the agent's actions that a human can follow without reading the tool log.
26
40
  * @returns a self-contained expression that evaluates to `true`.
27
41
  */
28
- export declare function cursorScript(x: number, y: number, action: CursorAction): string;
42
+ export declare function cursorScript(x: number, y: number, action: CursorAction, label?: string): string;
43
+ /**
44
+ * Forget the remembered position for a view.
45
+ *
46
+ * Called when the document is replaced: the overlay lived in the old document and
47
+ * went with it, so the cache would otherwise claim the pointer is already where it
48
+ * needs to be and skip every later paint — the pointer would simply never return
49
+ * after a navigation.
50
+ * @param handle - the view whose cache should be dropped.
51
+ */
52
+ export declare function forgetCursor<TView>(handle: TView): void;
29
53
  /**
30
54
  * Draw (or move) the pointer overlay on one view's page.
31
55
  *
@@ -38,5 +62,6 @@ export declare function cursorScript(x: number, y: number, action: CursorAction)
38
62
  * @param y - viewport y in CSS pixels.
39
63
  * @param action - whether to pulse a click ripple.
40
64
  * @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
65
+ * @param label - optional short description of the operation, shown beside the pointer.
41
66
  */
42
- export declare function paintCursor<TView>(handle: TView, x: number, y: number, action: CursorAction, evaluate: (handle: TView, expression: string) => Promise<unknown>): void;
67
+ export declare function paintCursor<TView>(handle: TView, x: number, y: number, action: CursorAction, evaluate: (handle: TView, expression: string) => Promise<unknown>, label?: string, force?: boolean): void;
@@ -17,15 +17,46 @@
17
17
  */
18
18
  /** Element id of the overlay root, stable so repeat calls update one node. */
19
19
  const CURSOR_ID = '__dsh_agent_cursor__';
20
+ /**
21
+ * Where the overlay was last placed, so an unchanged position is not repainted.
22
+ *
23
+ * A single tool call issues several CDP commands (locate, click, cursor), and the
24
+ * cursor is repainted for each one that moves it. Repainting to the same pixel is a
25
+ * wasted round-trip *and* a visible flicker: the transition restarts from the same
26
+ * place, which reads as a stutter rather than as movement.
27
+ */
28
+ const lastPlaced = new WeakMap();
29
+ /**
30
+ * Whether a paint at this position would change anything on screen.
31
+ * @param handle - the view the overlay belongs to.
32
+ * @param x - viewport x in CSS pixels.
33
+ * @param y - viewport y in CSS pixels.
34
+ * @param action - the action being reported.
35
+ * @param force - paint even when unchanged (a click ripple must always fire).
36
+ * @returns true when the overlay should be painted.
37
+ */
38
+ export function cursorNeedsPaint(handle, x, y, action, force = false) {
39
+ if (force)
40
+ return true;
41
+ const key = handle;
42
+ if (key === null || typeof key !== 'object')
43
+ return true;
44
+ const previous = lastPlaced.get(key);
45
+ return previous === undefined || previous.x !== x || previous.y !== y || previous.action !== action;
46
+ }
20
47
  /**
21
48
  * The in-page expression that materializes or moves the overlay. Idempotent: the
22
49
  * first call creates the nodes, later calls only move them (and pulse).
23
50
  * @param x - viewport x in CSS pixels.
24
51
  * @param y - viewport y in CSS pixels.
25
52
  * @param action - whether to pulse a click ripple at the point.
53
+ * @param label - optional short description of the operation, shown beside the
54
+ * pointer. The overlay is the only "the agent has taken over this tab" signal, so
55
+ * saying *what* it is doing is what turns it from a decoration into an account of
56
+ * the agent's actions that a human can follow without reading the tool log.
26
57
  * @returns a self-contained expression that evaluates to `true`.
27
58
  */
28
- export function cursorScript(x, y, action) {
59
+ export function cursorScript(x, y, action, label) {
29
60
  return `(() => {
30
61
  const id = ${JSON.stringify(CURSOR_ID)}
31
62
  let root = document.getElementById(id)
@@ -33,24 +64,55 @@ export function cursorScript(x, y, action) {
33
64
  root = document.createElement('div')
34
65
  root.id = id
35
66
  root.setAttribute('aria-hidden', 'true')
36
- root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
67
+ 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'
37
68
  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))">'
38
69
  + '<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>'
39
- 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>'
40
- root.innerHTML = svg + ripple
70
+ 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>'
71
+ // The bubble sits below-right of the pointer tip and is capped in width so a long
72
+ // label cannot cover the page it is describing.
73
+ 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>'
74
+ root.innerHTML = svg + ripple + bubble
41
75
  ;(document.body || document.documentElement).appendChild(root)
42
76
  }
43
77
  root.style.transform = 'translate(${x}px, ${y}px)'
44
- ${action === 'click' ? `const ripple = root.lastElementChild
78
+ ${action === 'click' ? `const ripple = root.querySelector('[data-dsh-role="ripple"]')
45
79
  if (ripple && typeof ripple.animate === 'function') {
46
80
  ripple.animate(
47
81
  [{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
48
82
  { duration: 550, easing: 'ease-out' },
49
83
  )
50
84
  }` : ''}
85
+ ${label !== undefined && label !== '' ? `const bubble = root.querySelector('[data-dsh-role="bubble"]')
86
+ if (bubble) {
87
+ bubble.textContent = ${JSON.stringify(label)}
88
+ if (typeof bubble.animate === 'function') {
89
+ // Fade in, hold, fade out: the label is a narration of the action, not a
90
+ // permanent caption, so a stale one never lingers over later work.
91
+ bubble.animate(
92
+ [{ opacity: 0, transform: 'translateY(-3px)' }, { opacity: 1, transform: 'translateY(0)' }, { opacity: 1, offset: 0.6 }, { opacity: 0 }],
93
+ { duration: 2200, easing: 'ease-out', fill: 'forwards' },
94
+ )
95
+ } else {
96
+ bubble.style.opacity = '1'
97
+ }
98
+ }` : ''}
51
99
  return true
52
100
  })()`;
53
101
  }
102
+ /**
103
+ * Forget the remembered position for a view.
104
+ *
105
+ * Called when the document is replaced: the overlay lived in the old document and
106
+ * went with it, so the cache would otherwise claim the pointer is already where it
107
+ * needs to be and skip every later paint — the pointer would simply never return
108
+ * after a navigation.
109
+ * @param handle - the view whose cache should be dropped.
110
+ */
111
+ export function forgetCursor(handle) {
112
+ const key = handle;
113
+ if (key !== null && typeof key === 'object')
114
+ lastPlaced.delete(key);
115
+ }
54
116
  /**
55
117
  * Draw (or move) the pointer overlay on one view's page.
56
118
  *
@@ -63,9 +125,17 @@ export function cursorScript(x, y, action) {
63
125
  * @param y - viewport y in CSS pixels.
64
126
  * @param action - whether to pulse a click ripple.
65
127
  * @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
128
+ * @param label - optional short description of the operation, shown beside the pointer.
66
129
  */
67
- export function paintCursor(handle, x, y, action, evaluate) {
130
+ export function paintCursor(handle, x, y, action, evaluate, label, force = false) {
68
131
  if (!Number.isFinite(x) || !Number.isFinite(y))
69
132
  return;
70
- void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => { });
133
+ const roundedX = Math.round(x);
134
+ const roundedY = Math.round(y);
135
+ if (!cursorNeedsPaint(handle, roundedX, roundedY, action, force))
136
+ return;
137
+ const key = handle;
138
+ if (key !== null && typeof key === 'object')
139
+ lastPlaced.set(key, { x: roundedX, y: roundedY, action });
140
+ void evaluate(handle, cursorScript(roundedX, roundedY, action, label)).catch(() => { });
71
141
  }
@@ -122,9 +122,20 @@ function parseFillValue(v) {
122
122
  return Number(v);
123
123
  return v ?? '';
124
124
  }
125
- /** Format a snapshot element list for the model. */
126
- function formatSnapshot(snapshot) {
127
- const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''} (${el.x},${el.y})`);
125
+ /**
126
+ * Format a snapshot element list for the model.
127
+ *
128
+ * Coordinates are opt-in. They are only meaningful to a caller that intends to
129
+ * click a pixel position, which means a vision pass; for the far more common case
130
+ * of locating an element by role/label and passing a semantic target, they are the
131
+ * bulkiest and least useful part of every line. Omitting them keeps the listing
132
+ * readable — which is exactly what a model without image input has to work from.
133
+ * @param snapshot - the snapshot payload.
134
+ * @param options - `coords: true` to include each element's viewport position.
135
+ */
136
+ function formatSnapshot(snapshot, options = {}) {
137
+ const showCoords = options.coords === true;
138
+ const lines = snapshot.elements.map(el => `[${el.ref}] ${el.kind}: ${el.label}${el.frame === true ? ' (iframe)' : ''}${showCoords ? ` (${el.x},${el.y})` : ''}`);
128
139
  const header = `URL: ${snapshot.url}${snapshot.title !== undefined ? `\nTitle: ${snapshot.title}` : ''}`;
129
140
  const body = lines.length > 0 ? lines.join('\n') : '(no interactive elements found)';
130
141
  const tail = snapshot.truncated === true ? '\n(snapshot truncated)' : '';
@@ -193,7 +204,10 @@ export function apply(ctx, config = {}) {
193
204
  },
194
205
  },
195
206
  },
196
- render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
207
+ // browser_open is an overview of what just loaded, not a locator: it has no
208
+ // `coords` parameter, so coordinates stay off here (browser_snapshot owns
209
+ // that switch for callers that actually target pixels).
210
+ render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
197
211
  },
198
212
  timeoutMs,
199
213
  isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
@@ -219,8 +233,10 @@ export function apply(ctx, config = {}) {
219
233
  }));
220
234
  ctx.tools.register(defineTool({
221
235
  name: 'browser_snapshot',
222
- description: 'Return an AI-friendly snapshot of the current shared-browser page: numbered interactive elements (inputs, buttons, links) the model can cite. Use this to understand an interactive page before driving it.',
223
- parameters: {},
236
+ 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.',
237
+ parameters: {
238
+ 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.' },
239
+ },
224
240
  output: {
225
241
  schema: {
226
242
  type: 'object',
@@ -256,7 +272,7 @@ export function apply(ctx, config = {}) {
256
272
  },
257
273
  },
258
274
  },
259
- render: (_args, value) => [{ type: 'text', text: formatSnapshot(value) }],
275
+ render: (args, value) => [{ type: 'text', text: formatSnapshot(value, { coords: args.coords === true }) }],
260
276
  },
261
277
  timeoutMs,
262
278
  isConcurrencySafe: () => true,
@@ -281,6 +297,7 @@ export function apply(ctx, config = {}) {
281
297
  parameters: {
282
298
  includeHidden: { type: 'boolean', description: 'Include hidden elements (default false).' },
283
299
  maxNodes: { type: 'number', description: 'Maximum nodes (default 500, range 10-5000).' },
300
+ 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.' },
284
301
  },
285
302
  output: {
286
303
  schema: {
@@ -313,14 +330,24 @@ export function apply(ctx, config = {}) {
313
330
  },
314
331
  },
315
332
  },
316
- render: (_args, value) => {
333
+ render: (args, value) => {
317
334
  const nodes = value.nodes;
335
+ // Built for reading, not for imaging: containment is shown as indentation
336
+ // (the depth was already collected and simply thrown away), an empty state
337
+ // list adds nothing, and coordinates appear only when asked for — a model
338
+ // working from the DOM never needs them, and they are the bulkiest part of
339
+ // every line.
340
+ const showCoords = args.coords === true;
318
341
  const lines = nodes.map(n => {
319
342
  const valuePart = n.value !== undefined && n.value !== null ? ` value="${String(n.value).slice(0, 60)}"` : '';
320
- return `[${n.ref}] ${n.role} "${n.name}"${valuePart} (${n.x},${n.y}) states=[${n.states.join(',')}]${n.frame === true ? ' (iframe)' : ''}`;
343
+ const indent = ' '.repeat(Math.max(0, Math.min(8, Number(n.depth) || 0)));
344
+ const coordsPart = showCoords ? ` (${n.x},${n.y})` : '';
345
+ const statePart = Array.isArray(n.states) && n.states.length > 0 ? ` states=[${n.states.join(',')}]` : '';
346
+ return `${indent}[${n.ref}] ${n.role} "${n.name}"${valuePart}${coordsPart}${statePart}${n.frame === true ? ' (iframe)' : ''}`;
321
347
  });
322
348
  const header = `URL: ${value.url}${value.title !== undefined ? `\nTitle: ${value.title}` : ''}`;
323
- return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}` }];
349
+ const hint = showCoords ? '' : '\n(coordinates omitted — pass coords: true to include them, or click by semantic target)';
350
+ return [{ type: 'text', text: `${header}\n\n${lines.length > 0 ? lines.join('\n') : '(no accessible interactive nodes)'}${value.truncated === true ? '\n(truncated)' : ''}${hint}` }];
324
351
  },
325
352
  },
326
353
  timeoutMs,
@@ -553,7 +580,7 @@ export function apply(ctx, config = {}) {
553
580
  }));
554
581
  ctx.tools.register(defineTool({
555
582
  name: 'browser_click',
556
- description: 'Click in the shared browser. Two ways: (1) semantic target — pass target {by: css|text|xpath, value, index?} and the element is located, scrolled into view and clicked at its center; (2) viewport coordinates (x/y) — use with browser_screenshot when a vision model locates an element on the screenshot (covers icons, image buttons, and canvas that DOM locators cannot target). Provide exactly one of target or x/y.',
583
+ 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.',
557
584
  parameters: {
558
585
  target: {
559
586
  type: 'object',
@@ -565,8 +592,8 @@ export function apply(ctx, config = {}) {
565
592
  },
566
593
  description: 'Locate the element semantically and click it (css/text/xpath).',
567
594
  },
568
- x: { type: 'number', description: 'Viewport x coordinate (CSS px), e.g. from a vision model reading the screenshot.' },
569
- y: { type: 'number', description: 'Viewport y coordinate (CSS px).' },
595
+ 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.' },
596
+ y: { type: 'number', description: 'Viewport y coordinate (CSS px), taken from the same screenshot as x.' },
570
597
  },
571
598
  output: {
572
599
  schema: { type: 'object', additionalProperties: false, properties: { clicked: { type: 'boolean', required: true } } },
@@ -1044,7 +1071,7 @@ export function apply(ctx, config = {}) {
1044
1071
  }));
1045
1072
  ctx.tools.register(defineTool({
1046
1073
  name: 'browser_screenshot',
1047
- description: 'Capture the current shared-browser page as a screenshot (PNG default, JPEG optional). Use for visual confirmation of layout, charts, designs, or CAPTCHAs, or to feed a vision tool (read_image) that locates elements visually. 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.',
1074
+ 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.',
1048
1075
  parameters: {
1049
1076
  fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (default false).' },
1050
1077
  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.' },
@@ -1296,6 +1323,8 @@ export function apply(ctx, config = {}) {
1296
1323
  parameters: {
1297
1324
  limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
1298
1325
  domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
1326
+ query: { type: 'string', description: 'Only visits whose URL or title contains this text (case-insensitive).' },
1327
+ 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.' },
1299
1328
  },
1300
1329
  output: {
1301
1330
  schema: {
@@ -1343,7 +1372,14 @@ export function apply(ctx, config = {}) {
1343
1372
  const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30;
1344
1373
  const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)));
1345
1374
  const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined;
1346
- const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} });
1375
+ const query = typeof args.query === 'string' && args.query.trim() !== '' ? args.query.trim() : undefined;
1376
+ const session = typeof args.session === 'string' && args.session.trim() !== '' ? args.session.trim() : undefined;
1377
+ const entries = browser.visited({
1378
+ limit,
1379
+ ...domain !== undefined ? { domain } : {},
1380
+ ...query !== undefined ? { query } : {},
1381
+ ...session !== undefined ? { session } : {},
1382
+ });
1347
1383
  return {
1348
1384
  count: entries.length,
1349
1385
  entries: entries.map(entry => ({