@cjylzs/dsh-browser 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CJYLZS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,233 @@
1
- # Temporary Holding Version
1
+ # dsh-browser
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ English | [中文](README.zh.md)
4
+
5
+ <img src="docs/img/preview_en.png" alt="The plugin at work: the agent's browser tool calls beside the same browser mirrored in the sidebar" width="100%">
6
+
7
+ <p align="center">
8
+ <img src="docs/img/settings_en.png" alt="The browser settings: one running instance per conversation with its own CDP port, window mode, automation markers, and profile location" width="45%">
9
+ </p>
10
+
11
+ **A real Chrome or Edge for the agent to work in, mirrored in the sidebar so you can watch and take over.** Every conversation gets its own browser with its own sign-ins. It runs in both the Web and the Desktop interface.
12
+
13
+ ## Summary
14
+
15
+ Chrome or Edge runs as its own process, with its own profile and its own CDP port, and every conversation gets its own: tabs, sign-ins, and ports are never shared between conversations. The agent drives it, and the sidebar shows the same pages — either side can act on them.
16
+
17
+ It is not an embedded web view. The page runs in an ordinary browser process, so sites see a normal browser, DevTools or another Playwright can attach to it while the mirror is live, and closing the harness does not leave a browser pretending to be part of the app.
18
+
19
+ <a id="highlights"></a>
20
+ ## Highlights
21
+
22
+ - **Works in both interfaces.** The Web GUI and the Desktop app run the same plugin against the same browser; install it once in each and nothing else differs, including the settings page.
23
+ - **One browser per conversation.** Tabs, sign-ins, and ports belong to one conversation and stay there: the sidebar pane names its session, and a tool call resolves its browser from the session that called it.
24
+ - **Watch it work, or take over.** Every page the browser opens gets its own sidebar tab with a live view, and clicks and typing in that tab reach the real page.
25
+ - **The agent can read a page it has never seen.** `browser_snapshot` prints the page's accessibility tree with a `ref` per node, which is how the agent reads an unfamiliar page and how it names the element to click; `find` narrows a large page, and `boxes` gives positions without a screenshot.
26
+ - **Input a site accepts.** Real mouse and text events at the element's own position — a page that ignores a synthetic `element.click()` still takes these — plus chords such as `Ctrl+A`, native dropdowns and checkboxes, dialogs answered by the call that opens them, and an explicit refusal when something else would receive the press.
27
+ - **A small tool set, and code for the rest.** `browser_evaluate` is the general-purpose one; the others cover what code cannot express, such as reading a page without knowing its selectors first, or asking what the page said while it failed. See [Tools](#tools).
28
+ - **Attachable.** Each browser listens on an external CDP port, so `chrome://inspect`, another Playwright, or the bundled `scripts/cdp.mjs` can attach to the same browser the sidebar is mirroring.
29
+ - **Working rules where the model reads them.** The plugin contributes a skill — read before acting, one action per observation, and a page's text is data rather than instructions — with five short recipes read only when their question comes up.
30
+
31
+ ## Table of Contents
32
+
33
+ - [Highlights](#highlights)
34
+ - [Install](#install)
35
+ - [Compatibility](#compatibility)
36
+ - [Usage](#usage)
37
+ - [Understand the design](#understand-the-design)
38
+ - [Configuration](#configuration)
39
+ - [Tools](#tools)
40
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
41
+ - [Dev Note](#dev-note)
42
+ - [Third-party code](#third-party-code)
43
+
44
+ -----
45
+
46
+ <a id="install"></a>
47
+ ## Install
48
+
49
+ Check the host dsh version first:
50
+
51
+ ```sh
52
+ dsh -V
53
+ ```
54
+
55
+ The published package is `@cjylzs/dsh-browser`:
56
+
57
+ ```sh
58
+ dsh plugin --profile web add @cjylzs/dsh-browser
59
+ ```
60
+
61
+ | Your dsh | Plugin version | Install command |
62
+ | --- | --- | --- |
63
+ | ≥ 0.1.7-rc.1 | v0.3.x (latest) | `dsh plugin --profile web add @cjylzs/dsh-browser` |
64
+ | ≥ 0.1.7-rc.1, from a tag | v0.2.x / v0.3.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.3.0` |
65
+ | 0.1.5-rc.2 | v0.1.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.1.0` |
66
+
67
+ The registry install takes the latest published version; a `github:` install needs the `#<tag>` ref that version pairs with, because a `github:` install without a ref takes the default branch HEAD, which drifts. The built `lib/` is committed with each tag, so a tag install needs no build step either.
68
+
69
+ The profile's `package.json` records what you installed; to change versions, re-add with the new spec, and to remove the plugin use `dsh plugin --profile web remove @cjylzs/dsh-browser`. Restart the harness after installing. A Chrome or Edge installation is required; `playwright-core` is a dependency and downloads no browser of its own.
70
+
71
+ ### Desktop
72
+
73
+ The Desktop app owns its own profile, and the ordinary CLI refuses to manage `--profile desktop` on purpose, so install through the Desktop CLI that ships with the app:
74
+
75
+ ```sh
76
+ "<Desktop install dir>/resources/runtime/cli/bin/dsh.cmd" plugin --profile desktop add @cjylzs/dsh-browser
77
+ ```
78
+
79
+ Use `<Desktop install dir>/resources/runtime/cli/bin/dsh` on macOS and Linux. Restart the app afterwards, the same way the Web profile restarts after `dsh plugin --profile web add`; the same `remove` verb uninstalls it.
80
+
81
+ ### From the Plugins page
82
+
83
+ Neither interface needs a terminal for this: **Plugins → Add plugin** takes the repository address with its `#<tag>` pin (the screenshot shows the bare address, and without a ref it takes the default branch HEAD), and **Install** applies it to the profile that is running — in the Desktop app that is the app's own profile, so the Desktop CLI above is not the only way in. The dialog says when the plugin waits for a restart; restart then.
84
+
85
+ <p align="center">
86
+ <img src="docs/img/ui_install_en.png" alt="The Plugins page in the Desktop app: the Add plugin dialog with a repository address in the field, and Install below it" width="60%">
87
+ </p>
88
+
89
+ -----
90
+
91
+ <a id="compatibility"></a>
92
+ ## Compatibility
93
+
94
+ A build targets one dsh generation, so install the plugin version that pairs with your dsh. The fork is in the client half — dsh 0.1.7-rc.1 replaced the settings model with no compatibility layer — which is why one build cannot serve both generations.
95
+
96
+ That fork is in the client half's imports, so one build cannot target both generations and the plugin pairs by generation:
97
+
98
+ - **v0.2.x → dsh ≥ 0.1.7-rc.1**, declared as `peerDependencies: >=0.1.7-rc.1` with no upper bound, so one build runs on the whole 0.2.0 line it is verified against ([why the cap went away](.agents/notes/implemented/process/2026-09-30-peer-range-floor-only.md)). The settings page's fields are marked volatile in the schema, and an edit reaches the running browsers through `loader/volatile-update`.
99
+ - **v0.1.x → dsh 0.1.5-rc.2**, declared as `peerDependencies: ^0.1.5-rc.2`. The settings page registers a namespace scope and renders its own controls.
100
+
101
+ An older dsh fails loudly: dsh activates a plugin only when its dsh peers satisfy the declared ranges, and names the plugin and the unsatisfied ranges when they do not. A later generation passes that check, so whether this build still runs on one is a question for verification rather than for the version number.
102
+
103
+ <a id="usage"></a>
104
+ ## Usage
105
+
106
+ Three steps, and the agent needs no instruction beyond what it is asked to do:
107
+
108
+ 1. **Nothing, usually.** The first tool call that needs a browser starts one, and the right sidebar opens itself on that session's Browser tab — you watch the page from its first frame. You can also open it by hand: **New tab → Browser**.
109
+ 2. **Ask for something.** "Open the docs and tell me what the install section says" is enough: the tools act on the same browser the pane shows, so you watch the work happen.
110
+ 3. **Attach your own tools when you want them.** `curl http://127.0.0.1:9333/json/version` answers while the browser runs; `chrome://inspect`, another Playwright over `chromium.connectOverCDP`, and `scripts/cdp.mjs` all attach to it.
111
+
112
+ The sidebar and the browser stay matched: one tab per page the browser has open, a new page adds a tab, closing a tab closes the page, and closing the last one stops the browser until something asks for another one. Which page the tools act on is the agent's choice (`browser_tabs`), independent of which tab you are looking at. [Understand the design](#understand-the-design) covers the rest.
113
+
114
+ -----
115
+
116
+ <a id="understand-the-design"></a>
117
+ ## Understand the design
118
+
119
+ The plugin keys everything by the harness session id, and that one decision explains most of its behavior:
120
+
121
+ - **Two independent paths resolve the same session.** The sidebar pane is registered into a session-scoped slot, so its registration receives the session id and names it on the viewer socket; a tool call has no pane, so it takes the session from the agent executing it (`exec.agent.id`, which is the same `SessionId` the API layer resumes sessions with). Neither path can reach a browser belonging to another conversation, and a tool called with no session — a scheduled job, or a subagent without one — fails instead of landing in somebody's browser.
122
+ - **One process per profile.** Chrome locks a profile directory to a single running process, so the configured profile directory is a *parent*: the browsers of one shared unit each get a subdirectory under it, named with the harness's own id escaping. Which unit depends on `profileMode` — one per conversation in `session` mode, one per project in `workspace` mode, and `temp` keeps nothing at all — so whether sign-ins carry over is the mode's answer alone. Stated plainly, because it is the price of the isolation: in the default mode **sign-ins are not shared between conversations, and do not survive the browser.** Sessions keep their identity across reopen, so a `session`-mode conversation returns to its own profile.
123
+ - **Ports are allocated, not assumed.** The configuration names a *window* of ports (`debugPortMin`–`debugPortMax`) because one number could never be the address: every session gets its own browser and its own listener. Each browser takes the lowest port in the window it can actually bind and holds it until it exits, because a probe's answer is stale the moment it is given. The top of the window is what stops a search from wandering into whatever else the machine runs: a browser that finds nothing free inside it fails with the window in the message. `GET /dsh-browser/status` reports the port each session ended up on; nothing should assume it is one of the configured values.
124
+ - **The mirror belongs to a CDP session, not to the pane.** `Page.startScreencast` is attached to one CDP session, and every path that starts a browser — the pane's restart, recovery from a crash, a launch setting change — replaces that session. A newly started browser therefore re-attaches the cast as soon as it is ready if anyone is watching, or a pane left open across a restart would keep its last frame forever while its status line kept updating.
125
+ - **A dead browser is replaced, not mourned.** Closing the window or losing the process moves the instance to `closed` with the reason, publishes it to the pane, and leaves the next request to start a fresh browser. Nothing has to be fixed by hand, including the browser process dying between two tool calls.
126
+ - **The snapshot is a contract with the model.** `Accessibility.getFullAXTree` is printed as one line per node, with ignored wrappers dropped and a `ref` for every node that has a DOM node behind it. A ref names a node on one page: navigating invalidates all of them, and a stale ref fails with a message pointing at `browser_snapshot` rather than clicking whatever now occupies that position.
127
+ - **A ref or a locator; an ambiguous locator is refused.** A ref is an answer one snapshot gave about one document, so it is cheap to check and goes stale. A locator — `role` with `name`, `text`, or `selector` — is the question that answer came from, asked again when the action runs, so a re-render or a navigation does not stop it. A locator matching more than one element is refused rather than guessed at, and the refusal lists every candidate with its nearest named ancestors, which is the only thing that tells two identically named controls apart. Matching reads the accessibility tree, so a locator matches exactly what a snapshot would print; a `selector` is walked by the page itself, so it reaches an open shadow root and a same-origin frame — the parts a query rooted at the document cannot see into, which is why `target` and `snapshotIgnore` resolve their selectors the same way.
128
+ - **Clicks are computed, not guessed.** A click resolves its ref to a backend node id, scrolls the element into view, reads its content quad, and converts that from page coordinates to the viewport coordinates CDP dispatches input in. It then sends a move, a press, and a release, which is what makes the event trusted.
129
+ - **A page is mirrored by its own tab.** The sidebar holds one tab per page the browser has open, addressed by the page's CDP target id, so two pages on the same address stay two tabs and a reloaded client re-attaches every tab to the page it was watching. A pane's frames, input, and address bar belong to its page, whether or not the tools are acting on it right now; a page the browser closes takes its tab with it, and closing a tab closes that page. **Every page the entry opens comes from one pick.** The page-less tab the browser entry opens asks the host for a page as it mounts — the ask is keyed by that tab record's own id, so a tab switched away and back, a Session change, or a client reload asks once — and the host opens one more page on a browser already running, or answers with the page a browser it started came up on. Which page the tools act on is `browser_tabs`'s `open`/`select`, independent of which pane the user is looking at.
130
+ - **The plugin's own routes carry the trust check.** The viewer socket, the status route, and the page route (open and close) are served by this plugin, and the webserver's route path runs no authentication of its own, so each applies Connection's rejection (`isTrustedApiRequest` and browser authentication) before answering. An unauthenticated request to any of them is refused with 401.
131
+ - **Settings are user overrides, not state.** The settings page writes to the harness settings document, and the `cordis.yml` entry stays the base layer beneath it; clearing a field removes the override. The profile settings are two questions rather than one asked twice: the mode decides who shares a sign-in and whether anything is kept, and the directory decides only where — which is why the directory row disappears in `temp` mode, where it could change nothing. Every field changes how a browser is launched or encoded, so a write restarts the running browsers and the panes reconnect on their own.
132
+
133
+ -----
134
+
135
+ <a id="configuration"></a>
136
+ ## Configuration
137
+
138
+ The settings section (Settings → Browser) edits the fields below and reports what is actually running, which is not the same thing as what is configured: the banner lists every session's browser with the port, window mode, and page count it really has. A deployment without a settings provider registers nothing, and the composition entry is the whole configuration.
139
+
140
+ | Field | Default | Meaning |
141
+ | --- | --- | --- |
142
+ | `channel` | `chrome` | Which installed browser to drive: `chrome` or `msedge`. |
143
+ | `executablePath` | empty | Explicit browser binary, for installs the channel lookup misses; overrides `channel`. |
144
+ | `headless` | `true` | Run without a window. Headless has nothing to occlude or minimize, so it is the recommended value. |
145
+ | `stealth` | `true` | Remove the two markers a Playwright-started browser carries: `navigator.webdriver` and, headless, a user agent spelled `HeadlessChrome/…`. |
146
+ | `userDataDir` | empty | Parent directory of the profiles a keeping mode resolves. Empty means that mode's default: a directory under the temporary directory for `session`, `<project>/.dsh/browser` for `workspace`. `temp` ignores it. Must be absolute. |
147
+ | `profileMode` | `temp` | Who shares a sign-in, and whether anything survives the browser: `temp` keeps nothing, `session` gives every conversation its own profile, `workspace` gives every conversation in one project the same one. |
148
+ | `debugPortMin` / `debugPortMax` | `9333` / `9400` | Window the per-session CDP ports are allocated from; each browser takes the lowest free one. Changing it moves where the *next* browser listens and leaves running browsers on the ports they already hold. |
149
+ | `viewportWidth` / `viewportHeight` | `1440` / `900` | Page size in headless mode, which has no window to take a size from. |
150
+ | `quality` | `70` | JPEG quality of mirrored frames. |
151
+ | `maxWidth` / `maxHeight` | `1600` / `1200` | Longest edge of a mirrored frame, in device pixels. |
152
+ | `everyNthFrame` | `1` | Mirror every Nth composited frame. |
153
+ | `snapshotNodes` | `300` | Most accessibility-tree nodes one `browser_snapshot` prints before it truncates and says so. |
154
+ | `maxInstances` | `4` | How many session browsers may run at once; reaching the limit fails the request rather than evicting a browser someone is watching. |
155
+ | `startupUrl` | `about:blank` | First address a browser opens. |
156
+ | `extraArgs` | `[]` | Additional browser arguments, appended after the plugin's own. |
157
+
158
+ `stealth` is on by default because it was measured, not assumed: with the markers in place Google served its "unusual traffic" page three times out of three, and with them removed three times out of three searches returned results — including for a real Edge window started the same way, which is what showed the markers rather than the browser to be the deciding factor. The setting exists so the comparison can be reproduced. When a deployment needs more than markers can hide, `executablePath` accepts any Chromium binary the user supplies, including hardened builds; no third-party binary is bundled or downloaded by this plugin.
159
+
160
+ -----
161
+
162
+ <a id="tools"></a>
163
+ ## Tools
164
+
165
+ | Tool | What it does |
166
+ | --- | --- |
167
+ | `browser_navigate` | Open an address; returns the final URL, the title, and the tab list. |
168
+ | `browser_snapshot` | Read the page as roles, names, and refs (`- button "Sign in" [ref=e2]`); returns the tab list too. `find` narrows it to what matches a text or a `/regex/`, with the path to each match; `boxes` adds each element's position in viewport pixels; `target` and `depth` take one subtree to a level. |
169
+ | `browser_click` | Click the element a snapshot ref names — or one found by `role` + `name`, `text`, or `selector` when the call runs — with real mouse events at its position. `button` picks the right or middle button, `double` sends the two press-release pairs a page reads as one double click; a press the page says something else would receive is refused, and `force` sends it anyway and reports what received it. A native `<select>` and a checkbox cannot be driven by a press — the popup belongs to the browser process and its options have no box to click, and a toggled checkbox writes no attribute — so `select: ["Two"]` picks an option by its value or its visible label and `checked: true|false` sets the state; each dispatches the page's own `input`/`change`, reports `selected`/`checked`, and sends no mouse event. |
170
+ | `browser_type` | Type into the element a ref names — or one found by `role` + `name`, `text`, or `selector` when the call runs — replacing its content by default, and press a `key` afterwards: a chord such as `Control+A` or `Shift+Tab` works. The characters to insert are `value`; `text` names an element, as it does on `browser_click`. With no element, `value` and `key` go to whatever the page has focused and nothing is replaced, which is how Escape closes a menu. It also reports `focused`, the element the focus ended on, which is the only way to see where a bare `Tab` sent it. |
171
+ | `browser_wait` | Wait until the page reaches a state, then say whether it did: `text`, `role` + `name`, `selector`, `url`, or `time` (a fixed wait, the last resort) — exactly one, with `timeoutMs` up to 30 s. A condition that has not held is not an error: the result carries `matched: false`, how long it waited, where the page is now, and what the page changed meanwhile. It waits on the page already open; it does not start a browser. |
172
+ | `browser_console` | Read what the page and the browser have said about themselves since the address loaded: console output, uncaught exceptions, and browser log entries such as a failed request or a blocked resource. Optional `levels`, `filter`, and `limit`. This is where a failure that leaves no mark in the DOM shows up, and it is the whole history, not just the last call — it is cleared when the page navigates. |
173
+ | `browser_screenshot` | Capture the page to a JPEG file and return its path: the viewport by default, `fullPage: true` for the whole document, or one element named the way the acting tools name one. `inline: true` also attaches the image to the result (needs a model that declares image input; the call is refused, with the file still available, when it does not). |
174
+ | `browser_evaluate` | Evaluate a JavaScript expression in the page: reading values, scrolling, `history.back()`, and anything else a tool argument list would express worse. A returned promise is awaited, a returned function is called, and top-level `await` and `return` both work. A string comes back as itself; anything else as JSON; a result too large to print is written to a file whose path comes back. |
175
+ | `browser_tabs` | See the browser's pages and change them: `list` reports every page — its id, its address, and which one the tools act on right now, from the same list the sidebar's tabs are built from; `open` asks for a new page and selects it; `select` makes a page that is already open the one the other tools act on; `close` closes one page, and closing the last page stops the browser. It never switches on its own: which page a pane shows and which page the tools act on are two things, and clicking a sidebar tab does not move the latter. |
176
+
177
+ **An action result carries a compact list of what changed.** `changed` says the page moved (`dom`), which does not distinguish a click that opened what it named from one that only repainted a spinner. So the action also reports the **first few** changes the page made, each in facts the page itself can stand behind — its tag, the `role` it declares, and a short piece of what it says: `dom: +1 status "Saved"; ~ p "24.1k" → "24.2k"; ~ button "Save" disabled: (none) → "true"`. The list is capped at five and the changes that did not fit are **counted** rather than dropped, and every entry is structured (`kind`/`tag`/`role`/`preview`/`attribute`/`from`/`to`) so a PTC program can branch on it. This is the page's own description of itself, for knowing where to look: the accessible roles and names are still `browser_snapshot`'s to report.
178
+
179
+ **A dialog is answered by the call that opens it.** A `confirm`, `prompt`, or `alert` blocks the page until it is answered, so there is no moment at which a later call could answer one: pass `dialog: "accept"` — with `dialogText` for a prompt — on the call that triggers it, or the default dismissal is reported in the result. Every dialog is answered before the call returns and none is silent: the result names what the page asked and how it was answered, and `changed` says `dialog`, so "the page did not change" can never be the report for a click a site stopped to confirm.
180
+
181
+ The set is deliberately small because `browser_evaluate` exists: a tool earns its place only by doing something code cannot, which here means trusted input, reading a page without knowing its selectors first, and the two questions that cannot be asked after the fact — whether a state has been reached (`browser_wait`), and what the page said about itself while it failed (`browser_console`, whose listener has to be subscribed before the page's own scripts run). All three are deliberate exceptions to "new capability becomes a parameter, not a tool name" (the third is `browser_tabs`: a page is not a property of the current page, and page script cannot see another target); the screenshot's shapes are the rule followed instead: `fullPage`, an element target, and `inline` are parameters on the tool that already existed. Choosing an option and setting a checkbox are the same shape — `select` and `checked` are parameters on `browser_click` — because a press can do neither. Deliberately absent are `back`/`forward`/`reload` (the pane has reload; history is one expression), hover, drag, upload, and download — each would be added when a real case needs it, not in anticipation.
182
+
183
+ Screenshots are written to a file, and the path comes back so it is durable in the session log. `inline: true` also attaches the image to the result, which costs no second call; the attachment is a reference the harness's attachment service mints (the plugin declares that service structurally and does not depend on its package), and the call is **refused before the capture** when the calling model does not declare image input — an image block is part of the tool result, so attaching one to a model that cannot read it would fail that request and every request after it.
184
+
185
+ -----
186
+
187
+ <a id="known-limitations-and-deferred-work"></a>
188
+ ## Known Limitations and Deferred Work
189
+
190
+ - **Verified on Windows only.** macOS and Linux have not been run once: the `channel` lookup, temporary directories, and process teardown are the parts most likely to differ.
191
+ - **The pane shows the page its tab names, and pages the browser opens on its own are followed only when the plugin is told about them.** A page the plugin learns of — a tool call, `window.open` from a page it drives — gets its own tab and its own mirror; a headful browser switched by its own tab strip keeps every mirror where it was, because the plugin is not told.
192
+ - **IME composition does not work in the sidebar.** During composition `event.key` is `Process` rather than a character, so nothing sensible can be forwarded. Outside composition the pane sends printable characters as text, named keys as key events, and a keystroke held with Ctrl, Alt or Meta — or Shift — as the chord it is, so Ctrl+A selects the page instead of typing an "a"; AltGr combinations still go as text, which is what they are on Windows (Ctrl+Alt). Pasting and plain ASCII typing are unaffected. The agent's `browser_type` is unaffected too: it inserts text rather than replaying keys.
193
+ - **The pane handles the clipboard shortcuts itself.** `Ctrl/Cmd+C`, `Ctrl/Cmd+X` and `Ctrl/Cmd+V` work in the pane: a copy or a cut puts the **mirrored page's selection** on your system clipboard, and a paste sends your clipboard text to the mirrored page (through `Input.insertText`, not by replaying keys). The page itself never receives those keystrokes — Chromium keeps the clipboard shortcuts in the browser process, where an injected key event does not go (measured: the page sees no `keydown` at all for them) — so this happens on the pane's side, where the keystroke is trusted. Plain text only; a page's own `navigator.clipboard.writeText` (a site's "copy" button) still writes the browser process's own clipboard, which in headless is not the system one.
194
+ - **A dialog is answered before its question can be read.** The page is blocked until the dialog is answered, so the answer has to be declared on the call that opens one; a call that did not declare it dismisses the dialog and reports that it did. The usual shape is two calls: the first reports what the page asked, the second answers it the other way.
195
+ - **Frames only when the page repaints.** `Page.startScreencast` is repaint-driven, so a page that never changes produces almost no frames and the pane keeps the last one. This is not a hang.
196
+ - **Not interactive while minimized.** Headless is unaffected, but a minimized headful window stops both frames and input.
197
+ - **`maxInstances` has no pane affordance.** Reaching the limit fails tool calls and refuses new panes with a message naming the limit, but the pane offers nothing to release one; the way to free a slot is closing a browser from its pane or disposing the session.
198
+ - **The tools do not follow the pane.** Clicking another browser tab in the sidebar changes what you are looking at, not which page the tools act on — deliberately (the pane is for the eyes, the tools are the agent's), and `browser_tabs`'s `select` is how the agent moves it.
199
+ - **Two picks during the same start are one page.** A browser that is starting joins both asks into the start it is already doing, and a start has one initial page; picks made once it is running add a page each.
200
+ - **No profile picker.** A session's profile is derived from its id, so there is no way to point this conversation at an existing Chrome profile, and no named list of profiles to choose from.
201
+ - **Downloads, uploads, file choosers, and permission prompts are not handled.** They happen in the browser process and are not surfaced or answered through the pane.
202
+ - **No request interception or network inspection.** The plugin drives a browser; it is not a proxy. Use the attached CDP port for that.
203
+ - **Calls without a session are refused.** A scheduled job or a subagent with no session of its own fails the browser tools by design; attributing its calls to some other conversation's browser would be worse than the failure.
204
+ - **Console history is per page and bounded.** `browser_console` keeps the last 200 entries of the current document (it says how many it dropped) and clears them when the page navigates: a message belongs to the document that produced it, the same way a ref does. Each message is clipped at 500 characters, and an argument that is an object is rendered from the protocol's own preview — enough for `{code: 500}`, not a deep dump.
205
+ - **A screenshot can become an image block** when the composition mounts the attachment service and the calling model declares image input (`inline: true`); otherwise the file path is the answer, and asking for `inline` on a model that cannot read images is refused with that reason rather than silently ignored.
206
+ - **Published as `@cjylzs/dsh-browser`.** The unscoped name `dsh-browser` on npm belongs to a different project, so it is not this one; install from the registry, a GitHub tag, or a local checkout — see [Install](#install).
207
+
208
+ -----
209
+
210
+ <a id="dev-note"></a>
211
+ ## Dev Note
212
+
213
+ The plugin directory is a self-contained pnpm workspace (`packages: [- .]`, `storeDir: .pnpm-store`) so pnpm cannot reach the harness repository's workspace. dsh framework packages are `peerDependencies` (`>=0.1.7-rc.1`, supplied by the host profile) and pinned exactly in `devDependencies` for local types and builds.
214
+
215
+ To run a local checkout instead of a tag, link it into the profile — later `pnpm run build` runs apply on the next harness restart without re-adding:
216
+
217
+ ```sh
218
+ cd dsh-browser
219
+ pnpm install # self-contained workspace; store lives in .pnpm-store/
220
+ pnpm run build # emits lib/index.js (host) and lib/client.js (browser)
221
+ dsh plugin --profile web add link:/absolute/path/to/dsh-browser
222
+ ```
223
+
224
+ Commands: `pnpm run build` (tsdown, both halves), `pnpm run typecheck`, `pnpm test`, and the measurement rigs in `scripts/` (`prove.mjs` for the CDP port, screen cast, input dispatch, and screenshot; `modes.mjs` for headless/windowed/minimized; `cdp.mjs` to read or drive a running browser over its external port, with `--port=` to pick a session's).
225
+
226
+ `pnpm test` runs on Node's own TypeScript support (`node --test "test/**/*.test.ts"`), with no loader dependency — which is also a constraint on the source: enum, parameter properties, and other non-erasable syntax do not run. Browser-dependent behavior is tested against a recording launcher (`test/support/browser.ts`) that hands out fake pages and CDP sessions, so the suite needs no Chrome. What such fakes cannot prove — the shape of a real accessibility tree, the coordinate space of a real click, whether a real click is trusted — was verified once against Chrome and lives in the decision notes under [`.agents/notes/`](.agents/notes/README.md).
227
+
228
+ The built `lib/` is committed, so commit the rebuilt output with any source change or a GitHub install serves stale code.
229
+
230
+ <a id="third-party-code"></a>
231
+ ### Third-party code
232
+
233
+ None is bundled. `lib/index.js` imports `playwright-core` (Apache-2.0) and `ws` (MIT) as runtime dependencies, and `lib/client.js` requires only the host shell's platform modules, so upstream security updates reach users through their own install rather than through this repository. The plugin ships no browser either: it drives the Chrome or Edge installation that is already on the machine, through `playwright-core`'s `channel` lookup, so the roughly 200 MB download a bundled browser would cost is not part of installing this.
package/README.zh.md ADDED
@@ -0,0 +1,233 @@
1
+ # dsh-browser
2
+
3
+ [English](README.md) | 中文
4
+
5
+ <img src="docs/img/preview_zh.png" alt="插件在工作:左侧是 agent 的浏览器工具调用,右侧栏镜像着同一台浏览器" width="100%">
6
+
7
+ <p align="center">
8
+ <img src="docs/img/settings_zh.png" alt="浏览器设置:每个对话一个正在运行的实例,各有自己的 CDP 端口,另含窗口模式、自动化标记与 profile 位置" width="45%">
9
+ </p>
10
+
11
+ **让 agent 在一台真实 Chrome 或 Edge 里干活,侧栏同步镜像,你能看着,也能随时接手。** 每个对话一台独立的浏览器,登录态各归各的。Web 界面与 Desktop 应用都能用。
12
+
13
+ ## 概述
14
+
15
+ Chrome 或 Edge 以独立进程运行,有自己的 profile、自己的 CDP 端口,而且每个对话各有一台:标签、登录态、端口都不跨对话共享。agent 驱动它,侧栏显示的是同一批页面——两边都能操作。
16
+
17
+ 它不是内嵌的网页视图。页面跑在一个普通浏览器进程里,所以站点看到的是正常浏览器,镜像运行时 DevTools 或另一个 Playwright 可以附加进来,关掉 harness 也不会留下一个假装属于界面的浏览器进程。
18
+
19
+ <a id="highlights"></a>
20
+ ## 特点
21
+
22
+ - **两个界面都能用。** Web 界面与 Desktop 应用跑的是同一个插件、同一台浏览器;各自装一次,之后没有任何差别,设置页也一样。
23
+ - **一个对话一台浏览器。** 标签、登录态、端口都属于一个对话:侧栏面板在它连接的 socket 上报出自己的会话,工具调用按调用来源的会话解析自己的浏览器。
24
+ - **看着它做,也可以随时接手。** 浏览器每开一个页面,侧栏就多一个带实时画面的标签,在标签里的点击与输入会到达真实页面。
25
+ - **不写选择器也能读陌生页面。** `browser_snapshot` 把页面的无障碍树打印成一行一个节点、每个节点带一个 `ref`——这既是 agent 读陌生页面的方式,也是它指名某个元素去点击的方式;`find` 把大页面收敛到回答问题的那些路径,`boxes` 不上截图就能说出元素在哪。
26
+ - **站点会接受的输入。** 在元素自身的位置派发真实鼠标与文本事件——忽略合成 `element.click()` 的页面同样接受——另含 `Ctrl+A` 这类组合键、原生下拉与勾选、由触发它的那一次调用回答的对话框,以及"会被别人接收"时的明确拒绝。
27
+ - **工具少而直接,其余交给代码。** `browser_evaluate` 是通用那个,其余只补代码表达不了的语义,比如不先知道选择器就能读页面、或者问页面失败时自己说了什么。见[工具](#tools)。
28
+ - **可附加。** 每台浏览器都监听一个对外 CDP 端口,`chrome://inspect`、另一个 Playwright、或随仓库的 `scripts/cdp.mjs` 都能附加到侧栏正在镜像的那一台。
29
+ - **工作纪律写在模型会读的地方。** 插件贡献一份技能——先读再动、一次观察一个状态变更动作、页面的文字是数据而不是指令——并附五篇按问题索引的短文,只有遇到那个问题才读。
30
+
31
+ ## 目录
32
+
33
+ - [特点](#highlights)
34
+ - [安装](#install)
35
+ - [版本适配](#compatibility)
36
+ - [使用](#usage)
37
+ - [理解设计](#understand-the-design)
38
+ - [配置](#configuration)
39
+ - [工具](#tools)
40
+ - [已知限制与待办](#known-limitations-and-deferred-work)
41
+ - [开发](#dev-note)
42
+ - [第三方代码](#third-party-code)
43
+
44
+ -----
45
+
46
+ <a id="install"></a>
47
+ ## 安装
48
+
49
+ 先确认宿主 dsh 版本:
50
+
51
+ ```sh
52
+ dsh -V
53
+ ```
54
+
55
+ 发布到 npm 的包名是 `@cjylzs/dsh-browser`:
56
+
57
+ ```sh
58
+ dsh plugin --profile web add @cjylzs/dsh-browser
59
+ ```
60
+
61
+ | 你的 dsh | 插件版本 | 安装命令 |
62
+ | --- | --- | --- |
63
+ | ≥ 0.1.7-rc.1 | v0.3.x(最新) | `dsh plugin --profile web add @cjylzs/dsh-browser` |
64
+ | ≥ 0.1.7-rc.1,按 tag 装 | v0.2.x / v0.3.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.3.0` |
65
+ | 0.1.5-rc.2 | v0.1.x | `dsh plugin --profile web add github:CJYLZS/dsh-browser#v0.1.0` |
66
+
67
+ 从 registry 安装取的是已发布的最新版本;从 GitHub 安装要写该版本对应的 `#<tag>`,因为不带 ref 的 `github:` 安装会取默认分支的 HEAD,那个位置会漂。构建产物 `lib/` 也随每个 tag 提交,所以按 tag 装不需要构建。
68
+
69
+ profile 的 `package.json` 会记下你装的是什么;换版本就用新 spec 重新 add,移除插件用 `dsh plugin --profile web remove @cjylzs/dsh-browser`。装完都要重启 harness。机器上需要已安装 Chrome 或 Edge;`playwright-core` 是运行依赖,它自己不会下载浏览器。
70
+
71
+ ### Desktop
72
+
73
+ Desktop 应用自己拥有它的 profile,而普通 CLI 有意拒绝管理 `--profile desktop`,所以走随应用一起发布的 Desktop CLI:
74
+
75
+ ```sh
76
+ "<Desktop 安装目录>/resources/runtime/cli/bin/dsh.cmd" plugin --profile desktop add @cjylzs/dsh-browser
77
+ ```
78
+
79
+ macOS 与 Linux 用 `<Desktop 安装目录>/resources/runtime/cli/bin/dsh`。装完重启应用,和 Web profile 用 `dsh plugin --profile web add` 之后一样;卸载用同一个 `remove`。
80
+
81
+ ### 从插件页安装
82
+
83
+ 两个界面都不需要终端:在**插件**页点**添加插件**,填仓库地址并带上 `#<tag>`(截图里是裸地址;不带 ref 会取默认分支 HEAD),再点**安装**——它装进当前正在运行的 profile,在 Desktop 应用里就是应用自己的 profile,所以上面那条 Desktop CLI 不是唯一的路。对话框会说插件要不要等重启,说要就重启。
84
+
85
+ <p align="center">
86
+ <img src="docs/img/ui_install_zh.png" alt="Desktop 应用的插件页:添加插件对话框里填着仓库地址,下方是安装按钮" width="60%">
87
+ </p>
88
+
89
+ -----
90
+
91
+ <a id="compatibility"></a>
92
+ ## 版本适配
93
+
94
+ 一份构建只针对一个 dsh 世代,按你的 dsh 选配对的那个插件版本。分叉落在客户端半边——dsh 0.1.7-rc.1 换掉了设置模型且没有兼容层——所以一份构建无法同时服务两个世代。
95
+
96
+ 这个分叉落在客户端半边的 import 上,因此一份构建无法同时针对两个世代,插件按世代配对:
97
+
98
+ - **v0.2.x → dsh ≥ 0.1.7-rc.1**,声明为 `peerDependencies: >=0.1.7-rc.1`,不封顶,所以同一份构建能跑在它实测过的整条 0.2.0 线上([为什么去掉封顶](.agents/notes/implemented/process/2026-09-30-peer-range-floor-only.md))。设置页的字段在 schema 上标为 volatile,编辑经 `loader/volatile-update` 抵达正在运行的浏览器。
99
+ - **v0.1.x → dsh 0.1.5-rc.2**,声明为 `peerDependencies: ^0.1.5-rc.2`。设置页注册 namespace scope 并自行渲染控件。
100
+
101
+ 版本旧了会明确失败:只有当插件的 dsh peer 满足声明的区间时 dsh 才激活它,否则报出插件名与未满足的区间。更高的世代会被这条检查放行,所以这份构建在某一代上还跑不跑得起来,是验证的问题而不是版本号的问题。
102
+
103
+ <a id="usage"></a>
104
+ ## 使用
105
+
106
+ 三步,而且 agent 不需要任何额外交代:
107
+
108
+ 1. **通常什么都不用做。** 第一次需要浏览器的工具调用会自己起一台,右侧栏随即自己打开到那个会话的「浏览器」标签——第一帧就看着。也可以手动打开:**新标签页 → 浏览器**。
109
+ 2. **让它做事。** 「打开文档,告诉我安装那一节写了什么」就够了:工具作用于面板显示的那台浏览器,所以你能看着事情发生。
110
+ 3. **需要时接上自己的工具。** 浏览器运行时 `curl http://127.0.0.1:9333/json/version` 有响应;`chrome://inspect`、用 `chromium.connectOverCDP` 的另一个 Playwright、以及 `scripts/cdp.mjs` 都能附加进去。
111
+
112
+ 侧栏与浏览器始终一一对应:浏览器每开一个页面就多一个标签,关掉标签就是关掉它镜像的那个页面,最后一个也关掉时浏览器停下,直到再有东西需要浏览器。工具作用于哪一页由 agent 决定(`browser_tabs`),与你看的是哪个标签无关。其余细节见[理解设计](#understand-the-design)。
113
+
114
+ -----
115
+
116
+ <a id="understand-the-design"></a>
117
+ ## 理解设计
118
+
119
+ 插件用 harness 的 session id 做一切索引,这一条决定解释了它的大部分行为:
120
+
121
+ - **两条互不相干的路径解析到同一个会话。** 侧栏面板注册在会话作用域的槽位里,于是它的注册工厂拿到 session id,并在 viewer socket 上报出它;工具调用没有面板,就从执行它的 agent 取会话(`exec.agent.id`,与 API 层恢复会话用的 `SessionId` 是同一个)。两条路径都够不到别的对话的浏览器;没有会话的调用(定时任务、没有会话的子代理)会直接失败,而不是落进某个人的浏览器。
122
+ - **一个进程一个 profile。** Chrome 的 profile 目录被单个运行中的进程独占,所以配置里的 profile 目录是**父目录**:同一个共享单位下的浏览器各有一个子目录,子目录名用 harness 自己的 id 转义规则生成。单位由 `profileMode` 决定——`session` 是一个对话一个,`workspace` 是一个项目一个,`temp` 则什么都不保留——所以"登录态会不会延续"只由模式回答。由此而来的结果值得直说,因为它是隔离的代价:默认模式下**登录态不跨会话共享,也不跨浏览器生命周期保留。** 会话在重新打开时保持自己的身份,所以 `session` 模式的对话会回到它自己的 profile。
123
+ - **端口是分配出来的,不是假设的。** 配置给的是一段**端口范围**(`debugPortMin`–`debugPortMax`),因为一个数字不可能是地址:每个会话各有一个浏览器、各自一个监听端口。每台浏览器在范围里取它能绑定的最小端口并持有到退出——因为探测给出的答案在给出的那一刻就已经过期。范围的上界是防止探测跑进这台机器上别的服务里:范围里没有空端口时它会直接报错,并把范围写进消息。`GET /dsh-browser/status` 报告每个会话最终落在哪个端口;任何地方都不应该假设它是配置里的某个值。
124
+ - **镜像属于 CDP 会话,不属于面板。** `Page.startScreencast` 挂在某一个 CDP 会话上,而每一条"起一台浏览器"的路径——面板重启、崩溃恢复、改启动项——都会换掉那个会话。因此新起的浏览器一就绪,只要还有人在看就重新挂流;否则一次重启之后仍开着的面板会永远停在最后一帧,而它的状态行却一直在更新。
125
+ - **浏览器死了会被换掉,而不是干等。** 关窗口或进程消失会把实例置为 `closed` 并带上原因、发布给面板,下一次请求就会起一台新的。没有任何情况需要手工修,包括浏览器进程恰好在两次工具调用之间死掉。
126
+ - **快照是与模型之间的约定。** `Accessibility.getFullAXTree` 被打印成一行一个节点,被 Chrome 标记忽略的包装节点被丢掉,每个背后有 DOM 节点的行都带一个 `ref`。ref 只属于某一页的某一次快照:导航会让它们全部失效,而过期的 ref 会报错并指向 `browser_snapshot`,绝不会点到那个位置上现在的东西。
127
+ - **ref 或定位:定位命中多个就拒绝,不猜。** ref 是某一次快照就某一份文档给出的答案——查起来便宜,但会过期。定位(`role` 配 `name`、`text`、`selector`)是这个答案背后的那个问题,在动作执行时才再问一遍,所以重渲染和导航都拦不住它。命中多个元素时**拒绝**而不是挑一个,而且拒绝里会列出每个候选项与它最近的具名祖先——那是区分两个同名控件的唯一事实。匹配读的是无障碍树,所以定位能匹配到的,恰好就是快照会打印出来的;`selector` 由页面自己走查,因此进得去开着的影子根与同源帧——那是从文档根查起看不见的地方,`target` 与 `snapshotIgnore` 用的是同一次走查。
128
+ - **点击是算出来的,不是猜的。** 一次点击把 ref 解析成 backend node id,把元素滚进视口,读它的 content quad,再把页面坐标换算成 CDP 派发输入所用的视口坐标;然后发出移动、按下、抬起三个事件——这正是它可信的原因。
129
+ - **一个页面有自己的标签。** 侧栏里浏览器每开一个页面就有一个标签,用页面的 CDP target id 作地址,所以两个同址页面是两个标签,客户端重载后每个标签也还能接回它原来在看的页面。一个面板的画面、输入与地址栏都属于它自己的页面——无论工具此刻作用于哪一页;浏览器关掉一个页面,它的标签跟着走,关掉一个标签就是关掉那个页面。**入口的每一页都来自一次点击**:引导页入口开出的那个没有页面的标签,挂载时会向 host 要一个页面(幂等键是这条标签记录自己的 id,所以切走再切回、刷新客户端都不会多开),host 对已经跑起来的浏览器开新页、对没跑起来的浏览器用它启动时的页面作答;工具作用的那一页则由 `browser_tabs` 的 `open`/`select` 决定,与面板在看哪一页无关。
130
+ - **插件自己的路由自带信任检查。** viewer socket、状态路由与页面路由(开页与关页)由本插件提供,而 webserver 的路由路径本身不做任何认证,所以每一条都在应答之前先走 Connection 的拒绝逻辑(`isTrustedApiRequest` 与浏览器认证)。未认证的请求会得到 401。
131
+ - **设置是用户覆盖,不是状态。** 设置页写入 harness 的 settings 文档,`cordis.yml` 里的条目始终是它下面的基础层;清空某项即删除覆盖。每个字段都改变浏览器的启动或编码方式,所以一次写入会重启正在运行的浏览器,面板会自行重连。
132
+
133
+ -----
134
+
135
+ <a id="configuration"></a>
136
+ ## 配置
137
+
138
+ 设置页(设置 → 浏览器)编辑下表字段,并报告**真正在跑的东西**——这与"配置成什么"不是一回事:横幅列出每个会话的浏览器,以及它实际持有的端口、窗口模式与页面数。没有设置服务的部署什么都不注册,组合配置就是全部配置。
139
+
140
+ | 字段 | 默认 | 说明 |
141
+ | --- | --- | --- |
142
+ | `channel` | `chrome` | 驱动哪个已安装的浏览器:`chrome` 或 `msedge`。 |
143
+ | `executablePath` | 空 | 显式指定浏览器可执行文件,用于 channel 查找不到的安装;设置后覆盖 `channel`。 |
144
+ | `headless` | `true` | 无窗口运行。无头没有可被遮挡或最小化的东西,是推荐值。 |
145
+ | `stealth` | `true` | 去掉 Playwright 启动的浏览器所带的两个标记:`navigator.webdriver`,以及无头时写成 `HeadlessChrome/…` 的 UA。 |
146
+ | `userDataDir` | 空 | 保留型模式解析出的 profile 的父目录。留空用该模式自己的默认位置:`session` 是临时目录下的一份,`workspace` 是 `<项目>/.dsh/browser`。`temp` 完全无视它。必须是绝对路径。 |
147
+ | `profileMode` | `temp` | 谁共享登录态、以及关掉之后还留不留:`temp` 什么都不保留,`session` 每个对话各一份,`workspace` 同一项目的全部对话共用一份。 |
148
+ | `debugPortMin` / `debugPortMax` | `9333` / `9400` | 每会话 CDP 端口的分配范围;每台浏览器取其中最小的空闲端口。改动只影响**之后**启动的浏览器,正在跑的浏览器保留它已经持有的端口。 |
149
+ | `viewportWidth` / `viewportHeight` | `1440` / `900` | 无头模式下的页面尺寸——无头没有真实窗口可取值。 |
150
+ | `quality` | `70` | 镜像帧的 JPEG 质量。 |
151
+ | `maxWidth` / `maxHeight` | `1600` / `1200` | 镜像帧的最大边长(设备像素)。 |
152
+ | `everyNthFrame` | `1` | 每 N 帧镜像一帧。 |
153
+ | `snapshotNodes` | `300` | 一次 `browser_snapshot` 最多打印多少个无障碍树节点,超出会被截断并注明。 |
154
+ | `maxInstances` | `4` | 同时允许多少个会话浏览器;到顶时请求报错,而不是驱逐正在被人看的浏览器。 |
155
+ | `startupUrl` | `about:blank` | 浏览器启动后首先打开的地址。 |
156
+ | `extraArgs` | `[]` | 追加的浏览器启动参数,接在插件自己的参数之后。 |
157
+
158
+ `stealth` 默认开启,因为这是**测出来的**而不是假设的:带标记时 Google 连续三次搜索全部被拦,去掉后三次全部返回结果——包括用同样方式启动的真实 Edge 窗口,正是它说明决定因素是标记而不是浏览器。这个开关存在,是为了让那次对照可以复现。当真需要标记之外的手段时,`executablePath` 接受用户自备的任意 Chromium 二进制(含加固过的构建);本插件不打包也不下载任何第三方二进制。
159
+
160
+ -----
161
+
162
+ <a id="tools"></a>
163
+ ## 工具
164
+
165
+ | 工具 | 作用 |
166
+ | --- | --- |
167
+ | `browser_navigate` | 打开地址;返回最终 URL、标题与标签列表。 |
168
+ | `browser_snapshot` | 把页面读成角色、名称与 ref(`- button "Sign in" [ref=e2]`);同样返回标签列表。`find` 只回匹配一段文本或 `/正则/` 的节点与到它的路径;`boxes` 给每个元素附上视口坐标;`target` 与 `depth` 取一棵子树到某一层。 |
169
+ | `browser_click` | 点击快照 ref 指名的元素,或点击调用那一刻才用 `role` + `name`、`text`、`selector` 找到的那个,在它自己的位置派发真实鼠标事件。`button` 选右键或中键,`double` 派发页面读作一次双击的那两组按下抬起;页面说这次点击会被别的元素收下时**默认拒绝**,加 `force` 才照发并回报是谁收下的。原生 `<select>` 与勾选框是**按不动的**——弹层属于浏览器进程、option 没有盒子可点,而勾选框被切换时不写任何 attribute——所以 `select: ["Two"]` 按 option 的 value 或可见 label 选中,`checked: true|false` 设置状态;两者都派发页面自己的 `input`/`change`、回报 `selected`/`checked`,且**不派鼠标事件**。 |
170
+ | `browser_type` | 向 ref 指名的元素输入,或向调用那一刻用 `role` + `name`、`text`、`selector` 找到的那个输入(默认替换原内容),之后可按一个 `key`——`Control+A`、`Shift+Tab` 这样的组合键同样可以。**要插入的字符是 `value`**;`text` 和 `browser_click` 上一样,是用来指名元素的(对应 Playwright 的 `getByText`),所以 `{ text: "Search", value: "univer" }` 就是"找到搜索框并输入 univer"。**不给元素**时 `value` 与 `key` 都发给页面当前焦点、什么都不替换,这也是"Escape 关掉菜单"的写法。它还会回报 `focused`——焦点最后落在哪个元素上,这是看清一次裸 `Tab` 把焦点送到哪里的唯一办法。 |
171
+ | `browser_wait` | 等到页面到达某个状态,然后说明到没到:`text`、`role` + `name`、`selector`、`url`,或 `time`(固定等待,最后手段)——**恰好一个**,`timeoutMs` 最大 30 s。条件没成立**不是错误**:结果里带 `matched: false`、等了多久、页面现在在哪、以及这段时间页面改了什么。它等的是**已经打开的那一页**,不会自己起浏览器。 |
172
+ | `browser_console` | 读页面与浏览器从当前地址加载以来"自己说过的话":console 输出、未捕获异常、以及浏览器日志条目(请求失败、资源被拦)。可选 `levels`、`filter`、`limit`。**不留 DOM 痕迹的失败只能在这里看见**,而且它是整段历史、不只是最近一次调用——导航即清空。 |
173
+ | `browser_screenshot` | 截图为 JPEG 文件并返回路径:默认视口,`fullPage: true` 整篇文档,或按动作工具同一套写法指定一个元素。`inline: true` 会把图片一并附在结果里(需要声明 image 输入的模型;不支持时会拒绝,而文件依然可要)。 |
174
+ | `browser_evaluate` | 在页面里求值 JavaScript 表达式:取值、滚动、`history.back()`,以及任何用参数列表表达会更糟的事。返回 promise 会被 await,返回函数会被调用,顶层 `await` 与 `return` 都能用;字符串原样返回,其余按 JSON 打印,大到印不下的结果会写进文件并把路径带回来。 |
175
+ | `browser_tabs` | 看这台浏览器的页面并改它们:`list` 列出每个页面(id、地址,以及**工具现在作用于哪一页**,和侧栏的标签是同一份列表);`open` 要一个新页面并切到它;`select` 把工具作用页换成已经打开的另一页;`close` 关掉一页,关掉最后一页就停下浏览器。它**不会自己切页**:面板显示哪一页与工具作用于哪一页是两件事,用户在侧栏点标签不会改后者。 |
176
+
177
+ **动作回报里还写着一份紧凑的变更清单。** `changed` 只说页面动过(`dom`),而"动过"分不出"点开了它指名的东西"和"只是重画了一个转圈"。所以动作返回时还会带上页面**头几条**具体变化,每一条只写页面自己说得出的事实(标签、它声明的 `role`、一小段它说的话):`dom: +1 status "先在实例表里点一行"; ~ p "24.1k" → "24.2k"; ~ button "启动并连接" disabled: (none) → "true"`。条数封顶(5 条),没写进去的**计数**而不是丢掉;每种变化都是结构化字段(`kind`/`tag`/`role`/`preview`/`attribute`/`from`/`to`),PTC 下的程序可以直接分支。这份清单是**页面自己**的描述,用来说明去哪里看;元素的无障碍 role 与名字仍以 `browser_snapshot` 为准。
178
+
179
+ **对话框由打开它的那次调用回答。** `confirm`、`prompt`、`alert` 会阻塞页面直到有人回答,所以不存在"后来的调用去回答"的时刻:在触发它的那次调用上带 `dialog: "accept"`(prompt 另带 `dialogText`),否则默认的拒绝(dismiss)会写进结果。每个对话框都在调用返回前被回答,而且没有一个是无声的:结果写明页面问的是什么、被怎么回答,`changed` 里出现 `dialog`——于是"页面没有变化"永远不会成为"站点停下来等你确认"那一次点击的回报。
180
+
181
+ 工具集刻意小,因为 `browser_evaluate` 就在那里:一个工具只有做到代码做不到的事才配得上自己的位置,而这里"做不到"指的是可信输入、不先知道选择器就能读页面,以及两个**不能事后补问**的问题——页面到没到某个状态(`browser_wait`),和它失败时自己说了什么(`browser_console`,它的监听必须在页面自己的脚本跑起来之前就装上)。这三条是"新能力做成参数、不加工具名"这条规则的**有意破例**(第三条是 `browser_tabs`:页面不是当前页的属性,页内 JS 也看不到别的 target);截图的几种形状则是规则本身:`fullPage`、元素目标、`inline` 都做成已有工具上的参数。下拉与勾选也是同一个形状——`select` 与 `checked` 是 `browser_click` 上的参数,因为一次按下做不到这两件事。刻意没有的是 `back`/`forward`/`reload`(面板上有刷新,历史就是一句表达式)、悬停、拖拽、上传、下载——每一个都等到真有场景需要时再加,而不是先加上等场景。
182
+
183
+ 截图总要写一个文件并把路径带回来(这样它在会话日志里是持久的);`inline: true` 额外把图片本身附在结果上,省掉第二次调用。附件引用由 harness 的附件服务签发(插件以**结构化声明**的方式使用该服务,不依赖它的包);而调用方模型若没有声明 image 输入,**在截图之前**就会被拒绝——图片块是工具结果的一部分,给读不了图的模型附上它,会失败那一次请求以及之后的每一次请求。
184
+
185
+ -----
186
+
187
+ <a id="known-limitations-and-deferred-work"></a>
188
+ ## 已知限制与待办
189
+
190
+ - **只在 Windows 上验证过。** macOS 与 Linux 一次没跑过:`channel` 的查找、临时目录、进程回收是最可能出差异的部分。
191
+ - **面板显示它自己的页面,浏览器自己开/关的页面只有插件被告知时才被跟随。** 插件得知的页面(工具调用、被驱动页面的 `window.open`)各有自己的标签与镜像;带窗口的浏览器用它自己的标签条切页时,各个镜像都停在原地,因为插件并不知情。
192
+ - **侧栏里走 IME 的中文输入不工作。** 组合输入期间 `event.key` 是 `Process` 而不是字符,没有可转发的东西。非组合状态下,面板把可打印字符按文本发送、命名键按按键事件发送、带 Ctrl/Alt/Meta(或 Shift)的按键按它本来的组合键发送——所以 `Ctrl+A` 是选中页面而不是往页面里插一个 "a";AltGr 组合仍按文本发送,因为它在 Windows 上就是 Ctrl+Alt。粘贴与直接键入 ASCII 不受影响;agent 的 `browser_type` 也不受影响,它插入文本而不是重放按键。
193
+ - **剪贴板快捷键由面板自己处理。** `Ctrl/Cmd+C`、`Ctrl/Cmd+X`、`Ctrl/Cmd+V` 在面板里可用:复制/剪切把**镜像页里的选区**写进你的系统剪贴板,粘贴把你的剪贴板文本发给镜像页(`Input.insertText`,不重放按键)。镜像页自己收不到这些按键——Chromium 把剪贴板快捷键放在浏览器进程,注入的按键事件到不了那里(实测页面连 `keydown` 都没有)——所以走的是面板这一侧,那里的按键是可信事件。只搬纯文本;页面里 JS 调 `navigator.clipboard.writeText`(站点自己的"复制"按钮)写的仍是浏览器进程内部那个剪贴板,headless 下不会到系统剪贴板。
194
+ - **对话框在问题被读到之前就要回答。** 页面会阻塞到对话框被回答为止,所以答案必须由打开它的那次调用声明;没声明的那次调用会拒绝(dismiss)它并如实上报。常见形状是两次调用:第一次告诉你页面问了什么,第二次换成另一种回答。
195
+ - **只在页面重绘时出帧。** `Page.startScreencast` 是重绘驱动的,完全静止的页面几乎不出帧,面板保留最后一帧。这不是卡住。
196
+ - **最小化时不可交互。** 无头模式不受影响,但被最小化的带窗口浏览器会同时停掉画面与输入。
197
+ - **`maxInstances` 在面板上没有对应动作。** 到顶时工具调用会失败、新面板会被拒绝,错误信息里点名了这个上限,但面板没有提供释放一个槽位的操作;唯一办法是在某个面板里关掉浏览器,或者让那个会话被销毁。
198
+ - **工具作用页不会跟着面板走。** 在侧栏里点另一个浏览器标签只换你在看的那一页,不会换工具作用的页面——这是有意的(面板给眼睛、工具给 agent),要换就用 `browser_tabs` 的 `select`。
199
+ - **浏览器正在启动的那一瞬间连点两次入口只会得到一页。** 启动中的两次请求会 join 同一次启动,而一次启动只有一页初始页面;等它起来之后再点,每点一次就多一页。
200
+ - **没有 profile 选择器。** 会话的 profile 由它的 id 推导,因此无法让这个对话指向一个已有的 Chrome profile,也没有可供选择的 profile 列表。
201
+ - **不处理下载、上传、文件选择器与权限弹窗。** 这些发生在浏览器进程里,既没有呈现在面板上,也无法通过面板应答。
202
+ - **不做请求拦截与网络检查。** 这个插件驱动浏览器,它不是代理;要做这类事请用附加的 CDP 端口。
203
+ - **没有会话的调用会被拒绝。** 定时任务或没有自己会话的子代理会按设计失败;把它的调用算到别的对话的浏览器上,比失败更糟。
204
+ - **console 历史按页保存且有上限。** `browser_console` 保留当前文档最近 200 条(丢了多少会报出来),导航即清空:一条消息属于产生它的那份文档,和 ref 同一个道理。每条消息截到 500 字符,对象参数按协议自带的 preview 渲染——够看到 `{code: 500}`,不是深挖。
205
+ - **截图可以变成图片内容块**:当组合挂载了附件服务、且调用方模型声明了 image 输入时(`inline: true`);否则文件路径就是答案,而在读不了图的模型上要 `inline` 会被**明确拒绝并说明原因**,不会静默忽略。
206
+ - **发布名是 `@cjylzs/dsh-browser`。** npm 上那个不带 scope 的 `dsh-browser` 是另一个项目,不是本插件;可以从 registry、GitHub tag 或本地检出安装——见[安装](#install)。
207
+
208
+ -----
209
+
210
+ <a id="dev-note"></a>
211
+ ## 开发
212
+
213
+ 插件目录是自包含的 pnpm workspace(`packages: [- .]`、`storeDir: .pnpm-store`),因此 pnpm 够不到 harness 仓库的 workspace。dsh 框架包声明为 `peerDependencies`(`>=0.1.7-rc.1`,由 host profile 提供),并在 `devDependencies` 里精确钉住,供本地类型与构建使用。
214
+
215
+ 想跑本地检出而不是 tag,就把它链接进 profile——之后 `pnpm run build` 的产物在下次重启 harness 时生效,不必重新 add:
216
+
217
+ ```sh
218
+ cd dsh-browser
219
+ pnpm install # 自包含 workspace;store 在 .pnpm-store/
220
+ pnpm run build # 产出 lib/index.js(host)与 lib/client.js(浏览器端)
221
+ dsh plugin --profile web add link:/absolute/path/to/dsh-browser
222
+ ```
223
+
224
+ 命令:`pnpm run build`(tsdown,两半都产出)、`pnpm run typecheck`、`pnpm test`,以及 `scripts/` 下的实测脚本(`prove.mjs` 验证 CDP 端口、screencast、输入派发与截图;`modes.mjs` 对照无头/带窗口/最小化;`cdp.mjs` 通过对外端口读写一台运行中的浏览器,用 `--port=` 指定某个会话的)。
225
+
226
+ `pnpm test` 跑在 Node 自己的 TypeScript 支持上(`node --test "test/**/*.test.ts"`),不依赖任何 loader——这同时是对源码的一条约束:enum、参数属性这类不可擦除语法跑不起来。涉及浏览器的行为用记录型启动器测试(`test/support/browser.ts` 发放假页面与假 CDP 会话),所以整个套件不需要 Chrome。而假件证明不了的部分——真实无障碍树的形状、真实点击的坐标空间、真实点击是否可信——都在真机上验证过一次,记在 [`.agents/notes/`](.agents/notes/README.md) 的决策笔记里。
227
+
228
+ 构建产物 `lib/` 是提交进仓库的:改完源码必须重新构建并一起提交,否则从 GitHub 装的人拿到的是旧代码。
229
+
230
+ <a id="third-party-code"></a>
231
+ ### 第三方代码
232
+
233
+ 没有打包任何第三方代码。`lib/index.js` 以运行时依赖的形式 import `playwright-core`(Apache-2.0)与 `ws`(MIT),`lib/client.js` 只需要宿主 shell 的平台模块——因此上游的安全更新通过用户自己的安装到达用户,而不是通过本仓库转发。插件也不附带浏览器:它通过 `playwright-core` 的 `channel` 查找驱动机器上已有的 Chrome 或 Edge,所以"捆绑浏览器"那种约 200MB 的下载不属于安装的一部分。
@@ -0,0 +1,13 @@
1
+ # dsh-browser bundle patch: inserts this plugin into a profile's layer stack.
2
+ #
3
+ # The plugin is ONE row. Its apply() mounts the browser manager, the tools, and
4
+ # the screencast route together; there is no base row to replace, because the
5
+ # capability (driving a real local Chrome and mirroring it into the right
6
+ # Sidebar) does not exist in the harness.
7
+ #
8
+ # The tab type registers under its own kind rather than taking over `browser`:
9
+ # a later harness ships `ui-sidebar-browser` under that kind, and that tab
10
+ # renders pages inside the app while this one mirrors a separate process.
11
+ - insert:
12
+ - id: dsh-browser
13
+ name: '@cjylzs/dsh-browser'