@restregular/dsh-visual-browser 0.1.0 → 0.1.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.md CHANGED
@@ -1,124 +1,129 @@
1
- # dsh-visual-browser
2
-
3
- A DeepSeek Harness plugin that gives the Agent a **real Chrome** and gives you a **window into it**.
4
-
5
- - The Agent never reads the DOM. It reads a numbered list of *visible interactive
6
- elements* plus a short text excerpt — a few hundred tokens per page instead of a
7
- full snapshot.
8
- - Every action the Agent takes (click, type, key, scroll, navigation) is mirrored
9
- into a **面板 (panel)** with the page frame, an operation timeline you can replay,
10
- and a picture you can click to take over the browser yourself.
11
- - Cookies persist across restarts (one dedicated Chrome profile), so a login the
12
- Agent performs once keeps working.
13
-
14
- ## Install
15
-
16
- ```bash
17
- # The plugin is a normal DSH bundle; install it from the registry
18
- dsh plugin add @restregular/dsh-visual-browser
19
- ```
20
-
21
- Or from the Harness UI: **Plugins → Add → `@restregular/dsh-visual-browser`**.
22
-
23
- Requirements: Google Chrome, Microsoft Edge, or Chromium on the machine
24
- (auto-detected), and Node.js 22 or newer (the plugin ships no runtime
25
- dependencies; it talks CDP over the built-in WebSocket).
26
-
27
- ## Use
28
-
29
- 1. Click the **浏览器 / Browser** icon in the sidebar to open the panel.
30
- 2. Ask the Agent something that needs a website, for example
31
- *"打开 example.com 看看首页写了什么"*. The Agent calls `browser_open`.
32
- 3. Watch the frame update. Click the picture to click the page; wheel over it to
33
- scroll; use the toolbar for back / forward / reload / live view / stop.
34
-
35
- ## Tools the Agent gets
36
-
37
- | Tool | Purpose |
38
- | --- | --- |
39
- | `browser_open` | Open a URL (launches Chrome on first use) and return the numbered view. |
40
- | `browser_see` | Fresh numbered view of the current page. |
41
- | `browser_act` | `click` / `type` / `key` / `hover` / `scroll` / `select` / `back` / `forward` / `reload` / `wait` / `close`, then return the new view. |
42
- | `browser_text` | Read text of the page or of one element (a link also returns its `href`). |
43
- | `browser_screenshot` | Save a PNG/JPEG to disk and return the path (feed it to `read_image`). |
44
-
45
- ### What the Agent sees
46
-
47
- ```
48
- URL: https://example.com/login
49
- TITLE: 登录 - Example
50
- VIEW: 1280x800 scroll 0/2400 · 4/6 interactive
51
- [1] textbox "手机号" value=13800000000
52
- [2] textbox "密码" value=••••••
53
- [3] button "登录"
54
- [4] link "忘记密码" → /forgot
55
- HEADINGS: 用户登录 | 没有账号?
56
- TEXT: 用户登录 手机号 密码 登录 忘记密码
57
- ACTIVE: [1] textbox "手机号"
58
- ```
59
-
60
- Numbers are reassigned on every view, so an action always uses the newest
61
- numbers; a stale one fails with a clear "take a fresh snapshot" message instead
62
- of clicking the wrong element.
63
-
64
- ## Configuration
65
-
66
- Row config (in the profile's `cordis.patch.yml`, override by row id
67
- `dsh-visual-browser`):
68
-
69
- ```yaml
70
- - id: dsh-visual-browser
71
- config:
72
- headless: true # false = visible Chrome window
73
- executablePath: '' # explicit chrome.exe path
74
- userDataDir: '' # default: ~/.dsh/visual-browser/chrome-profile
75
- viewport:
76
- width: 1280
77
- height: 800
78
- maxElements: 100 # cap on the numbered list
79
- maxTextChars: 320 # cap on the text excerpt
80
- frameQuality: 62 # JPEG quality of panel frames
81
- timelineFrames: 24 # replayable frames kept in memory
82
- allowUserControl: true # allow clicks from the panel
83
- actionTimeoutMs: 12000
84
- navigationTimeoutMs: 30000
85
- ```
86
-
87
- ## How it works
88
-
89
- - **Host** (`index.js`, `lib/`): launches Chrome with
90
- `--remote-debugging-port=0 --user-data-dir=<profile>`, parses the DevTools
91
- endpoint from stderr, attaches one flattened CDP session, and exposes:
92
- - five tools through the `tools` service (registration is effect-scoped),
93
- - a same-origin prefix route `/visual-browser/api` (`/state`, `/frame`,
94
- `/live`, `/start`, `/stop`, `/capture`, `/click`, `/wheel`, `/key`, `/nav`)
95
- that the panel polls and posts to.
96
- - **Snapshot** (`lib/page-scripts.js`): one serialized function runs in the page,
97
- filters interactive elements to the viewport, assigns `data-dsh-vb-ref`
98
- numbers, and returns structured JSON. Nothing else about the DOM leaves the
99
- page.
100
- - **Frames** stay on the host: the panel displays the newest JPEG by sequence
101
- number, so a live picture costs no session tokens. The timeline holds the last
102
- N frames; clicking a row replays that moment.
103
- - **One code path**: panel clicks, wheel events and keys run the same
104
- `BrowserManager` methods the tools use.
105
-
106
- Chrome is killed with its process tree when the plugin unloads.
107
-
108
- ## Notes and limits
109
-
110
- - Cross-origin iframes are counted but not inspected; open the direct URL when a
111
- flow lives inside a frame.
112
- - Blocking JS dialogs are auto-accepted and logged in the timeline.
113
- - The panel route is served by the Harness web server on its existing origin;
114
- it performs no extra authentication, matching the other local panel routes.
115
- - Headless mode is the default. Some sites behave differently headless; set
116
- `headless: false` when a site insists on a window.
117
-
118
- ## Development
119
-
120
- ```bash
121
- npm test # syntax checks + snapshot/format/route/package smoke tests
122
- ```
123
-
124
- MIT © RestRegular
1
+ # dsh-visual-browser
2
+
3
+ A DeepSeek Harness plugin that gives the Agent a **real Chrome** and gives you a **window into it**.
4
+
5
+ - The Agent never reads the DOM. It reads a numbered list of *visible interactive
6
+ elements* plus a short text excerpt — a few hundred tokens per page instead of a
7
+ full snapshot.
8
+ - Every action the Agent takes (click, type, key, scroll, navigation) is mirrored
9
+ into a **面板 (panel)** with the page frame, an operation timeline you can replay,
10
+ and a picture you can click to take over the browser yourself.
11
+ - Cookies persist across restarts (one dedicated Chrome profile), so a login the
12
+ Agent performs once keeps working.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ # The plugin is a normal DSH bundle; install it from the registry
18
+ dsh plugin add @restregular/dsh-visual-browser
19
+ ```
20
+
21
+ Or from the Harness UI: **Plugins → Add → `@restregular/dsh-visual-browser`**.
22
+
23
+ Requirements: Google Chrome, Microsoft Edge, or Chromium on the machine
24
+ (auto-detected), and Node.js 22 or newer (the plugin ships no runtime
25
+ dependencies; it talks CDP over the built-in WebSocket).
26
+
27
+ ## Use
28
+
29
+ 1. Click the **浏览器 / Browser** icon in the sidebar to open the panel.
30
+ 2. Ask the Agent something that needs a website, for example
31
+ *"打开 example.com 看看首页写了什么"*. The Agent calls `browser_open`.
32
+ 3. Watch the frame update. Click the picture to click the page; wheel over it to
33
+ scroll; use the toolbar for back / forward / reload / live view / stop.
34
+
35
+ ## Tools the Agent gets
36
+
37
+ | Tool | Purpose |
38
+ | --- | --- |
39
+ | `browser_open` | Open a URL (launches Chrome on first use) and return the numbered view. |
40
+ | `browser_see` | Fresh numbered view of the current page. |
41
+ | `browser_act` | `click` / `type` / `key` / `hover` / `scroll` / `select` / `back` / `forward` / `reload` / `wait` / `close`, then return the new view. |
42
+ | `browser_text` | Read text of the page or of one element (a link also returns its `href`). |
43
+ | `browser_screenshot` | Save a PNG/JPEG to disk and return the path (feed it to `read_image`). |
44
+
45
+ ### What the Agent sees
46
+
47
+ ```
48
+ URL: https://example.com/login
49
+ TITLE: 登录 - Example
50
+ VIEW: 1280x800 scroll 0/2400 · 4/6 interactive
51
+ [1] textbox "手机号" value=13800000000
52
+ [2] textbox "密码" value=••••••
53
+ [3] button "登录"
54
+ [4] link "忘记密码" → /forgot
55
+ HEADINGS: 用户登录 | 没有账号?
56
+ TEXT: 用户登录 手机号 密码 登录 忘记密码
57
+ ACTIVE: [1] textbox "手机号"
58
+ ```
59
+
60
+ Numbers are reassigned on every view, so an action always uses the newest
61
+ numbers; a stale one fails with a clear "take a fresh snapshot" message instead
62
+ of clicking the wrong element.
63
+
64
+ ## Configuration
65
+
66
+ Row config (in the profile's `cordis.patch.yml`, override by row id
67
+ `dsh-visual-browser`):
68
+
69
+ ```yaml
70
+ - id: dsh-visual-browser
71
+ config:
72
+ headless: true # false = visible Chrome window
73
+ executablePath: '' # explicit chrome.exe path
74
+ userDataDir: '' # default: ~/.dsh/visual-browser/chrome-profile
75
+ viewport:
76
+ width: 1280
77
+ height: 800
78
+ maxElements: 100 # cap on the numbered list
79
+ maxTextChars: 320 # cap on the text excerpt
80
+ frameQuality: 62 # JPEG quality of panel frames
81
+ timelineFrames: 24 # replayable frames kept in memory
82
+ allowUserControl: true # allow clicks from the panel
83
+ reuseExisting: true # adopt a Chrome that already owns the profile
84
+ actionTimeoutMs: 12000
85
+ navigationTimeoutMs: 30000
86
+ ```
87
+
88
+ ## How it works
89
+
90
+ - **Host** (`index.js`, `lib/`): launches Chrome with
91
+ `--remote-debugging-port=0 --user-data-dir=<profile>`, parses the DevTools
92
+ endpoint from stderr, attaches one flattened CDP session, and exposes:
93
+ - five tools through the `tools` service (registration is effect-scoped),
94
+ - a same-origin prefix route `/visual-browser/api` (`/state`, `/frame`,
95
+ `/live`, `/start`, `/stop`, `/capture`, `/click`, `/wheel`, `/key`, `/nav`)
96
+ that the panel polls and posts to.
97
+ - **Snapshot** (`lib/page-scripts.js`): one serialized function runs in the page,
98
+ filters interactive elements to the viewport, assigns `data-dsh-vb-ref`
99
+ numbers, and returns structured JSON. Nothing else about the DOM leaves the
100
+ page.
101
+ - **Frames** stay on the host: the panel displays the newest JPEG by sequence
102
+ number, so a live picture costs no session tokens. The timeline holds the last
103
+ N frames; clicking a row replays that moment.
104
+ - **One code path**: panel clicks, wheel events and keys run the same
105
+ `BrowserManager` methods the tools use.
106
+ - **Survives a host restart**: Chrome writes `DevToolsActivePort` into its
107
+ profile, so a restarted host (or one whose previous host crashed with Chrome
108
+ still alive) attaches to that browser instead of failing on the profile lock.
109
+ An adopted browser is only detached — its process is not killed.
110
+
111
+ Chrome is killed with its process tree when the plugin unloads.
112
+
113
+ ## Notes and limits
114
+
115
+ - Cross-origin iframes are counted but not inspected; open the direct URL when a
116
+ flow lives inside a frame.
117
+ - Blocking JS dialogs are auto-accepted and logged in the timeline.
118
+ - The panel route is served by the Harness web server on its existing origin;
119
+ it performs no extra authentication, matching the other local panel routes.
120
+ - Headless mode is the default. Some sites behave differently headless; set
121
+ `headless: false` when a site insists on a window.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ npm test # syntax checks + snapshot/format/route/package smoke tests
127
+ ```
128
+
129
+ MIT © RestRegular
package/lib/browser.js CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { spawn } from 'node:child_process';
10
10
  import { existsSync, mkdirSync } from 'node:fs';
11
- import { writeFile } from 'node:fs/promises';
11
+ import { readFile, writeFile } from 'node:fs/promises';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
@@ -45,6 +45,7 @@ export const DEFAULT_CONFIG = {
45
45
  frameQuality: 62,
46
46
  timelineFrames: 24,
47
47
  allowUserControl: true,
48
+ reuseExisting: true,
48
49
  actionTimeoutMs: 12_000,
49
50
  navigationTimeoutMs: 30_000,
50
51
  startupTimeoutMs: 30_000,
@@ -73,6 +74,7 @@ export function normalizeConfig(input = {}) {
73
74
  frameQuality: clamp(source.frameQuality, 20, 95, DEFAULT_CONFIG.frameQuality),
74
75
  timelineFrames: clamp(source.timelineFrames, 4, 120, DEFAULT_CONFIG.timelineFrames),
75
76
  allowUserControl: source.allowUserControl === undefined ? DEFAULT_CONFIG.allowUserControl : source.allowUserControl !== false,
77
+ reuseExisting: source.reuseExisting === undefined ? DEFAULT_CONFIG.reuseExisting : source.reuseExisting !== false,
76
78
  actionTimeoutMs: clamp(source.actionTimeoutMs, 500, 60_000, DEFAULT_CONFIG.actionTimeoutMs),
77
79
  navigationTimeoutMs: clamp(source.navigationTimeoutMs, 1000, 120_000, DEFAULT_CONFIG.navigationTimeoutMs),
78
80
  startupTimeoutMs: clamp(source.startupTimeoutMs, 2000, 120_000, DEFAULT_CONFIG.startupTimeoutMs),
@@ -213,6 +215,7 @@ export class BrowserManager {
213
215
  #targetId = null;
214
216
  #starting = null;
215
217
  #running = false;
218
+ #owned = false;
216
219
  #executable = '';
217
220
  #url = 'about:blank';
218
221
  #title = '';
@@ -281,6 +284,22 @@ export class BrowserManager {
281
284
  } catch (error) {
282
285
  throw new BrowserError('PROFILE_DIR_FAILED', `Could not create the browser profile directory ${userDataDir}: ${error.message}`);
283
286
  }
287
+ if (this.#config.reuseExisting) {
288
+ const reused = await this.#reuseProfile(userDataDir).catch((error) => {
289
+ this.#logger?.info?.(`[visual-browser] reuse probe failed: ${error?.message ?? error}`);
290
+ return false;
291
+ });
292
+ if (reused) {
293
+ this.#owned = false;
294
+ await this.#attach();
295
+ this.#running = true;
296
+ this.#lastError = '';
297
+ await this.#capture({ force: true }).catch(() => null);
298
+ this.#record('start', userDataDir, 'attached to a running Chrome');
299
+ this.#logger?.info?.('[visual-browser] attached to the Chrome that already owns this profile');
300
+ return;
301
+ }
302
+ }
284
303
  const args = [
285
304
  '--remote-debugging-port=0',
286
305
  `--user-data-dir=${userDataDir}`,
@@ -326,7 +345,11 @@ export class BrowserManager {
326
345
  if (match) finish(null, match[1]);
327
346
  };
328
347
  const onExit = (code) => {
329
- finish(new BrowserError('BROWSER_EXITED', `Chrome exited with code ${code} before it was ready. ${stderr.slice(-600)}`));
348
+ const hint =
349
+ stderr.trim() === '' && code === 0
350
+ ? 'Chrome handed the launch to a browser that already owns this profile, but no DevTools endpoint answered. Close the leftover browser (the panel Stop button, or Task Manager) or set a different `userDataDir`.'
351
+ : stderr.slice(-600);
352
+ finish(new BrowserError('BROWSER_EXITED', `Chrome exited with code ${code} before it was ready. ${hint}`));
330
353
  };
331
354
  const onError = (error) => {
332
355
  finish(new BrowserError('SPAWN_FAILED', `Could not start ${executable}: ${error.message}`));
@@ -345,6 +368,7 @@ export class BrowserManager {
345
368
  });
346
369
 
347
370
  const cdp = await CdpConnection.open(endpoint);
371
+ this.#owned = true;
348
372
  this.#cdp = cdp;
349
373
  child.once('exit', () => {
350
374
  if (this.#proc === child) this.#proc = null;
@@ -356,8 +380,49 @@ export class BrowserManager {
356
380
  await this.#attach();
357
381
  this.#running = true;
358
382
  this.#lastError = '';
359
- this.#record('start', '', `${this.#config.headless ? 'headless' : 'windowed'} Chrome started`);
360
383
  await this.#capture({ force: true }).catch(() => null);
384
+ this.#record('start', '', `${this.#config.headless ? 'headless' : 'windowed'} Chrome started`);
385
+ }
386
+
387
+ /**
388
+ * Chrome writes `<userDataDir>/DevToolsActivePort` when it starts with remote
389
+ * debugging. A host restart, or a crashed host whose Chrome survived, can
390
+ * therefore adopt the running browser instead of failing on the profile lock.
391
+ */
392
+ async #reuseProfile(userDataDir) {
393
+ const portFile = path.join(userDataDir, 'DevToolsActivePort');
394
+ let text = '';
395
+ try {
396
+ text = await readFile(portFile, 'utf8');
397
+ } catch {
398
+ return false;
399
+ }
400
+ const lines = text.split(/\r?\n/).map((line) => line.trim());
401
+ const port = Number(lines[0]);
402
+ const wsPath = lines[1] ?? '';
403
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return false;
404
+ if (!wsPath.startsWith('/devtools/browser/')) return false;
405
+ let cdp = null;
406
+ try {
407
+ cdp = await CdpConnection.open(`ws://127.0.0.1:${port}${wsPath}`, { timeoutMs: 3000 });
408
+ await cdp.send('Browser.getVersion');
409
+ } catch {
410
+ try {
411
+ cdp?.close();
412
+ } catch {
413
+ /* already closed */
414
+ }
415
+ return false;
416
+ }
417
+ this.#cdp = cdp;
418
+ cdp.onClose(() => {
419
+ if (this.#cdp === cdp) {
420
+ this.#running = false;
421
+ this.#sessionId = null;
422
+ this.#targetId = null;
423
+ }
424
+ });
425
+ return true;
361
426
  }
362
427
 
363
428
  async #attach() {
@@ -398,17 +463,32 @@ export class BrowserManager {
398
463
  });
399
464
  }
400
465
 
401
- /** Shut Chrome down; safe to call when it never started. */
466
+ /** Shut Chrome down (or just detach when this host did not spawn it). */
402
467
  async close() {
403
468
  const proc = this.#proc;
469
+ const owned = this.#owned;
470
+ const cdp = this.#cdp;
471
+ const targetId = this.#targetId;
404
472
  this.#proc = null;
473
+ this.#owned = false;
405
474
  this.#running = false;
406
475
  this.#sessionId = null;
407
476
  this.#targetId = null;
408
- const cdp = this.#cdp;
409
477
  this.#cdp = null;
410
- cdp?.close();
411
- if (proc !== null) {
478
+ if (owned) {
479
+ cdp?.close();
480
+ } else if (cdp !== null && targetId !== null) {
481
+ // Only our own tab is ours to close: another host may still use the browser.
482
+ try {
483
+ await Promise.race([cdp.send('Target.closeTarget', { targetId }), sleep(1000)]);
484
+ } catch {
485
+ /* the browser may already be gone */
486
+ }
487
+ cdp.close();
488
+ } else {
489
+ cdp?.close();
490
+ }
491
+ if (owned && proc !== null) {
412
492
  try {
413
493
  if (process.platform === 'win32') {
414
494
  spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
@@ -505,8 +585,8 @@ export class BrowserManager {
505
585
  return this.#serial(async () => {
506
586
  await this.#navigate(target);
507
587
  const snapshot = await this.snapshot();
508
- this.#record('open', target, snapshot.title || '');
509
588
  await this.#capture().catch(() => null);
589
+ this.#record('open', target, snapshot.title || '');
510
590
  return snapshot;
511
591
  });
512
592
  }
@@ -791,8 +871,8 @@ export class BrowserManager {
791
871
  }
792
872
  await this.#settleAfterAction();
793
873
  const snapshot = await this.snapshot();
794
- this.#record(action, target.ref !== undefined ? `[${target.ref}]` : (target.selector ?? ''), summary.slice(0, 160));
795
874
  await this.#capture().catch(() => null);
875
+ this.#record(action, target.ref !== undefined ? `[${target.ref}]` : (target.selector ?? ''), summary.slice(0, 160));
796
876
  return { summary, snapshot };
797
877
  });
798
878
  }
@@ -810,8 +890,8 @@ export class BrowserManager {
810
890
  await this.#clickResolved({ x: px, y: py, name: '' });
811
891
  await this.#settleAfterAction();
812
892
  const snapshot = await this.snapshot();
813
- this.#record('user-click', `${Math.round(x * 100)}%,${Math.round(y * 100)}%`, `you clicked ${Math.round(x * 100)}%, ${Math.round(y * 100)}%`);
814
893
  await this.#capture().catch(() => null);
894
+ this.#record('user-click', `${Math.round(x * 100)}%,${Math.round(y * 100)}%`, `you clicked ${Math.round(x * 100)}%, ${Math.round(y * 100)}%`);
815
895
  return { summary: 'clicked in the panel', snapshot };
816
896
  });
817
897
  }
@@ -830,8 +910,8 @@ export class BrowserManager {
830
910
  pointerType: 'mouse',
831
911
  });
832
912
  await sleep(180);
833
- this.#record('user-scroll', `${deltaY || deltaX}px`, `you scrolled ${Math.abs(deltaY || deltaX)}px`);
834
913
  await this.#capture().catch(() => null);
914
+ this.#record('user-scroll', `${deltaY || deltaX}px`, `you scrolled ${Math.abs(deltaY || deltaX)}px`);
835
915
  return { summary: 'scrolled in the panel' };
836
916
  });
837
917
  }
@@ -843,8 +923,8 @@ export class BrowserManager {
843
923
  return this.#serial(async () => {
844
924
  const spec = await this.#press(key);
845
925
  await this.#settleAfterAction();
846
- this.#record('user-key', spec.key, `you pressed ${spec.key}`);
847
926
  await this.#capture().catch(() => null);
927
+ this.#record('user-key', spec.key, `you pressed ${spec.key}`);
848
928
  return { summary: `pressed ${spec.key}` };
849
929
  });
850
930
  }
@@ -871,6 +951,7 @@ export class BrowserManager {
871
951
  const filePath = requested !== '' ? path.resolve(requested) : path.join(tmpdir(), 'dsh-visual-browser', `page-${timestamp}.${format}`);
872
952
  mkdirSync(path.dirname(filePath), { recursive: true });
873
953
  await writeFile(filePath, bytes);
954
+ await this.#capture().catch(() => null);
874
955
  this.#record('screenshot', path.basename(filePath), `saved ${format} screenshot`);
875
956
  return { path: filePath, bytes: bytes.length, format };
876
957
  });
@@ -937,6 +1018,7 @@ export class BrowserManager {
937
1018
  starting: this.#starting !== null,
938
1019
  executable: this.#executable,
939
1020
  headless: this.#config.headless,
1021
+ managed: this.#owned,
940
1022
  url: this.#url,
941
1023
  title: this.#title,
942
1024
  viewport: { width: this.#config.viewportWidth, height: this.#config.viewportHeight },