dsh-chrome-control 0.1.5

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 ADDED
@@ -0,0 +1,97 @@
1
+ # dsh-chrome
2
+
3
+ A DeepSeek Harness bundle that gives the agent control of the user's **real
4
+ Chrome** — their profile, their logins, their open tabs — as `mcp__chrome__*`
5
+ tools.
6
+
7
+ ## How it fits together
8
+
9
+ ```
10
+ agent ──MCP Streamable HTTP──▶ chrome-daemon ──WebSocket──▶ extension ──CDP──▶ page
11
+ ```
12
+
13
+ The daemon is a standard MCP server, so this bundle needs **no tool code of its
14
+ own**. Its patch contributes three rows:
15
+
16
+ | Row | Job |
17
+ |---|---|
18
+ | `chrome-daemon` | Installs the daemon binary if needed and starts it. |
19
+ | `chrome-mcp` | The in-box `@deepseek-ai/dsh-mcp-client` bridge, pointed at the daemon. This is the entire tool surface. |
20
+ | `chrome-skills` | Registers the bundled skill that teaches the agent how to drive those tools. |
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ dsh plugin --profile web add dsh-chrome-control # or a link: dependency for local dev
26
+ ```
27
+
28
+ Then **install the browser extension yourself** — see
29
+ `extension/dsh-chrome/README.md`. Loading an unpacked extension is a decision
30
+ only the browser's owner can make, so nothing does it for you. Until it is
31
+ loaded and enabled, every tool returns a message saying exactly that.
32
+
33
+ ## Configuration
34
+
35
+ ```yaml
36
+ - id: chrome-daemon
37
+ name: 'dsh-chrome/daemon'
38
+ config:
39
+ port: 10086 # must match the mcp-client row's url
40
+ autoStart: true # false: never launch the daemon, just use one if present
41
+ ```
42
+
43
+ The default port is **10086**, because the stock Kimi WebBridge
44
+ daemon uses 10086 and the two are allowed to coexist. If you change `port`,
45
+ change the `chrome-mcp` row's `url` to match — a test enforces that they agree.
46
+
47
+ ### How the binary is found
48
+
49
+ In order: an existing `~/.dsh-chrome/bin/chrome-daemon`; a prebuilt binary in
50
+ this package's `bin/chrome-daemon-<platform>-<arch>`; otherwise a
51
+ `cargo build --release` of the sibling `crates/chrome-daemon` checkout. If none
52
+ of those work the bundle logs one actionable line and **loads anyway** — a
53
+ missing browser bridge must never stop a session from starting.
54
+
55
+ ## Behaviour worth knowing
56
+
57
+ - **Startup order does not matter.** The mcp-client row sets
58
+ `failOnStartupError: false`, so a daemon that is not up yet does not abort the
59
+ profile; the bridge's reconnect policy attaches as soon as it answers. No
60
+ harness restart is needed.
61
+ - **Someone else's daemon is never touched.** If the port answers but does not
62
+ identify itself as `dsh-chrome`, the bundle logs a warning telling you to pick
63
+ another port and stops. It never kills a process it does not own.
64
+ - **The daemon is never stopped or restarted automatically.**
65
+
66
+ ## Tools
67
+
68
+ `navigate`, `find_tab`, `list_tabs`, `close_tab`, `close_session`,
69
+ `snapshot`, `click`, `fill`, `evaluate`, `screenshot`, `save_as_pdf`,
70
+ `mouse_click`, `key_type`, `send_keys` — each prefixed `mcp__chrome__`.
71
+
72
+ These are distinct from the harness's built-in `browser_*` tools, which drive a
73
+ separate WebKit panel sharing no cookies or logins with Chrome. The bundled
74
+ skill tells the agent when to use which.
75
+
76
+ ## What this bundle does and does not do
77
+
78
+ | Does | Does not |
79
+ |---|---|
80
+ | Install and start the daemon | Install the browser extension |
81
+ | Point the in-box MCP bridge at it | Implement any tool itself |
82
+ | Register a skill describing the tools | Download binaries from the network |
83
+ | Refuse to disturb a foreign process on the port | Stop or restart anything automatically |
84
+
85
+ ## Security
86
+
87
+ While the extension is connected the agent acts with **your logged-in
88
+ sessions**, and `mouse_click`/`key_type` send *trusted* input that pages cannot
89
+ distinguish from your own. The extension popup has an **Allow agent control**
90
+ toggle that severs this immediately; `autoStart: false` keeps the daemon from
91
+ launching on its own. The daemon binds loopback only and sends no telemetry.
92
+
93
+ ## Tests
94
+
95
+ ```bash
96
+ pnpm test # patch contract, skill resolution, daemon supervision
97
+ ```
@@ -0,0 +1,32 @@
1
+ # dsh-chrome bundle: three rows that together give the agent control of the
2
+ # user's real Chrome.
3
+ #
4
+ # 1. the daemon row makes sure the local MCP server binary exists and runs;
5
+ # 2. the mcp-client row is the *only* tool surface — the daemon speaks MCP
6
+ # Streamable HTTP, so the in-box bridge publishes its catalog as
7
+ # mcp__chrome__* with no adapter code of our own;
8
+ # 3. the skills row teaches the agent how to drive those tools.
9
+ #
10
+ # The browser extension is installed by the user, by design: loading an
11
+ # unpacked extension is a decision only the browser's owner can make.
12
+ - insert:
13
+ - id: chrome-daemon
14
+ name: 'dsh-chrome/daemon'
15
+ config:
16
+ port: 10086
17
+ autoStart: true
18
+
19
+ - id: chrome-mcp
20
+ name: '@deepseek-ai/dsh-mcp-client'
21
+ config:
22
+ serverName: chrome
23
+ transport: streamable-http
24
+ url: http://127.0.0.1:10086/mcp
25
+ toolCallTimeoutMs: 30000
26
+ # The daemon may still be starting, and the extension may be attached
27
+ # later; a failed first connect must not abort the profile. The bridge's
28
+ # own reconnect policy then attaches as soon as the daemon answers.
29
+ failOnStartupError: false
30
+
31
+ - id: chrome-skills
32
+ name: 'dsh-chrome/skills'
package/lib/daemon.js ADDED
@@ -0,0 +1,146 @@
1
+ import { createRequire } from "node:module";
2
+ import { spawn } from "node:child_process";
3
+ import { chmod, copyFile, mkdir, stat } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ //#region src/daemon.ts
7
+ /**
8
+ * Daemon lifecycle for the dsh-chrome bundle.
9
+ *
10
+ * This plugin owns *installation and supervision only*. The agent-facing tools
11
+ * come from the in-box `@deepseek-ai/dsh-mcp-client` row in this bundle's
12
+ * patch, because `chrome-daemon` speaks MCP Streamable HTTP directly.
13
+ *
14
+ * The daemon binary is resolved in three steps, each preferred over the next:
15
+ * a binary already installed under the harness home, a prebuilt binary shipped
16
+ * inside this package, or a local `cargo build` of the sibling crate. When none
17
+ * of those work the plugin logs one actionable line and loads anyway — a
18
+ * missing browser bridge must never stop a session from starting.
19
+ *
20
+ * @module dsh-chrome/daemon
21
+ */
22
+ /** Stable Cordis plugin name. */
23
+ const name = "chrome-daemon";
24
+ /** The daemon's own `serverInfo.name`, used to recognize our own process. */
25
+ const DAEMON_IDENTITY = "dsh-chrome";
26
+ /** Where the installed binary lives. */
27
+ function installedBinaryPath(home = homedir()) {
28
+ return join(home, ".dsh-chrome", "bin", "chrome-daemon");
29
+ }
30
+ /** Platform token used to name prebuilt binaries shipped in this package. */
31
+ function platformToken(platform = process.platform, arch = process.arch) {
32
+ return `${platform}-${arch}`;
33
+ }
34
+ /** This package's own directory, resolved from its installed location. */
35
+ function packageRoot() {
36
+ const require = createRequire(import.meta.url);
37
+ return dirname(require.resolve("../package.json"));
38
+ }
39
+ async function isFile(path) {
40
+ try {
41
+ return (await stat(path)).isFile();
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** Ask a running daemon who it is. */
47
+ async function probe(port, signal) {
48
+ try {
49
+ const response = await fetch(`http://127.0.0.1:${port}/status`, { signal: signal ?? AbortSignal.timeout(1500) });
50
+ if (!response.ok) return void 0;
51
+ return await response.json();
52
+ } catch {
53
+ return;
54
+ }
55
+ }
56
+ /**
57
+ * Locate a usable daemon binary, installing or building one when needed.
58
+ * @returns the binary's path, or `undefined` when none could be produced.
59
+ */
60
+ async function resolveBinary(logger) {
61
+ const installed = installedBinaryPath();
62
+ if (await isFile(installed)) return installed;
63
+ const root = packageRoot();
64
+ const prebuilt = join(root, "bin", `chrome-daemon-${platformToken()}`);
65
+ if (await isFile(prebuilt)) {
66
+ await mkdir(dirname(installed), { recursive: true });
67
+ await copyFile(prebuilt, installed);
68
+ await chmod(installed, 493);
69
+ return installed;
70
+ }
71
+ const crate = resolve(root, "..", "..", "crates", "chrome-daemon");
72
+ if (await isFile(join(crate, "Cargo.toml"))) {
73
+ const built = await cargoBuild(crate, logger);
74
+ if (built) {
75
+ await mkdir(dirname(installed), { recursive: true });
76
+ await copyFile(built, installed);
77
+ await chmod(installed, 493);
78
+ return installed;
79
+ }
80
+ }
81
+ }
82
+ /** Build the crate in release mode and return the artifact path. */
83
+ async function cargoBuild(crate, logger) {
84
+ logger?.info("building chrome-daemon from source (first run only)");
85
+ if (!await new Promise((settle) => {
86
+ const child = spawn("cargo", ["build", "--release"], {
87
+ cwd: crate,
88
+ stdio: "ignore"
89
+ });
90
+ child.once("error", () => settle(false));
91
+ child.once("exit", (code) => settle(code === 0));
92
+ })) {
93
+ logger?.warn("cargo build failed; install Rust or ship a prebuilt binary");
94
+ return;
95
+ }
96
+ const artifact = join(crate, "target", "release", "chrome-daemon");
97
+ return await isFile(artifact) ? artifact : void 0;
98
+ }
99
+ /** Start the daemon and wait until it answers, or report why it did not. */
100
+ async function start(binary, port, logger) {
101
+ await new Promise((settle) => {
102
+ const child = spawn(binary, [
103
+ "start",
104
+ "--port",
105
+ String(port)
106
+ ], { stdio: "ignore" });
107
+ child.once("error", () => settle());
108
+ child.once("exit", () => settle());
109
+ });
110
+ for (let attempt = 0; attempt < 30; attempt += 1) {
111
+ if ((await probe(port))?.name === "dsh-chrome") return true;
112
+ await new Promise((wait) => setTimeout(wait, 100));
113
+ }
114
+ logger?.warn("chrome-daemon did not become ready; see ~/.dsh-chrome/logs/daemon.log");
115
+ return false;
116
+ }
117
+ /**
118
+ * Ensure a daemon is serving `port`, installing and starting one if allowed.
119
+ * @param ctx - plugin context, used only for logging.
120
+ * @param config - port and auto-start policy.
121
+ */
122
+ async function apply(ctx, config = {}) {
123
+ const port = config.port ?? 10086;
124
+ const autoStart = config.autoStart !== false;
125
+ const running = await probe(port);
126
+ if (running) {
127
+ if (running.name === "dsh-chrome") {
128
+ ctx.logger?.info(`chrome-daemon already running on ${port}`);
129
+ return;
130
+ }
131
+ ctx.logger?.warn(`port ${port} is served by "${running.name ?? "unknown"}", not chrome-daemon; set this row's port (and the mcp-client url) to a free port`);
132
+ return;
133
+ }
134
+ if (!autoStart) {
135
+ ctx.logger?.info("chrome-daemon autoStart is off; start it yourself when needed");
136
+ return;
137
+ }
138
+ const binary = await resolveBinary(ctx.logger);
139
+ if (!binary) {
140
+ ctx.logger?.warn("chrome-daemon is not installed. Build it with: cargo build --release (in crates/chrome-daemon), or ship a prebuilt binary in this package's bin/ directory.");
141
+ return;
142
+ }
143
+ await start(binary, port, ctx.logger);
144
+ }
145
+ //#endregion
146
+ export { DAEMON_IDENTITY, apply, installedBinaryPath, name, platformToken, probe, resolveBinary };
package/lib/skills.js ADDED
@@ -0,0 +1,40 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import * as SkillFilesystem from "@deepseek-ai/dsh-skill-filesystem";
4
+ //#region src/skills.ts
5
+ /**
6
+ * dsh-chrome companion plugin: mounts a dsh-skill-filesystem provider scoped to
7
+ * this package's own bundled `skills/` directory, so the Chrome skill is
8
+ * discovered without a manual copy step. Resolves its own installed location
9
+ * through `createRequire`, the technique DeepSeek Harness's own bundles use to
10
+ * find their installed assets.
11
+ *
12
+ * @module dsh-chrome/skills
13
+ */
14
+ /** Stable Cordis plugin name. */
15
+ const name = "chrome-skills";
16
+ /** Provider name registered on `ctx.skills`. */
17
+ const SKILL_PROVIDER_NAME = "chrome";
18
+ /**
19
+ * Resolve this package's bundled `skills/` directory from its own installed
20
+ * location, independent of the caller's working directory.
21
+ * @returns the absolute path to the bundled skills directory.
22
+ */
23
+ function resolveBundledSkillsDir() {
24
+ const require = createRequire(import.meta.url);
25
+ return join(dirname(require.resolve("../package.json")), "skills");
26
+ }
27
+ /**
28
+ * Mount the bundled skill directory as an isolated provider, contributing only
29
+ * this package's own skill and no default roots.
30
+ * @param ctx - plugin context; skill-filesystem injects `skills` from it.
31
+ */
32
+ function apply(ctx) {
33
+ ctx.plugin(SkillFilesystem, {
34
+ providerName: SKILL_PROVIDER_NAME,
35
+ includeDefaultRoots: false,
36
+ customSkillDirs: [resolveBundledSkillsDir()]
37
+ });
38
+ }
39
+ //#endregion
40
+ export { SKILL_PROVIDER_NAME, apply, name, resolveBundledSkillsDir };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "dsh-chrome-control",
3
+ "version": "0.1.5",
4
+ "description": "DeepSeek Harness (DSH) bundle: installs and supervises the chrome-daemon MCP server, exposing the user's real Chrome to the agent as mcp__chrome__* tools.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": { "access": "public" },
8
+ "engines": { "node": ">=22.19" },
9
+ "main": "lib/daemon.js",
10
+ "exports": {
11
+ ".": "./lib/daemon.js",
12
+ "./daemon": "./lib/daemon.js",
13
+ "./skills": "./lib/skills.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib/**/*.js",
19
+ "cordis.patch.yml",
20
+ "skills/**/*"
21
+ ],
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ }
26
+ },
27
+ "peerDependencies": {
28
+ "@deepseek-ai/dsh": ">=0.1.1-rc.2"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@deepseek-ai/dsh": { "optional": true }
32
+ },
33
+ "devDependencies": {
34
+ "@deepseek-ai/cordis": "^4.0.1",
35
+ "@deepseek-ai/cordis-plugin-include": "^1.0.6",
36
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
37
+ "@deepseek-ai/dsh-app-boot": "^0.1.1-rc.2",
38
+ "@deepseek-ai/dsh-mcp-client": "^0.1.1-rc.2",
39
+ "@deepseek-ai/dsh-skill": "^0.1.1-rc.2",
40
+ "@deepseek-ai/dsh-skill-filesystem": "^0.1.1-rc.2",
41
+ "@deepseek-ai/dsh-tools": "^0.1.1-rc.2",
42
+ "@types/node": "^22.20.1",
43
+ "tsdown": "^0.22.14",
44
+ "typescript": "^5.7.0",
45
+ "vitest": "^4.1.11"
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown",
49
+ "prepare": "tsdown",
50
+ "typecheck": "tsc --noEmit -p tsconfig.json",
51
+ "test": "vitest run",
52
+ "check": "npm run typecheck && npm run build && npm run test"
53
+ }
54
+ }
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: chrome
3
+ description: |
4
+ Drive the user's real Chrome browser — navigate, click, type, read pages, screenshot — using their actual logged-in sessions. Use this skill whenever the user wants to act on a website that needs their login, automate a browser task, or read a page that the harness's own browser cannot reach. Also use when the user says "my browser", "my Chrome", "the tab I have open", or names a site they are signed in to.
5
+ ---
6
+
7
+ # Chrome (real browser, real sessions)
8
+
9
+ These tools drive the **user's own Chrome**, with their cookies and logins, through a local daemon and a browser extension. Tools appear as `mcp__chrome__*`.
10
+
11
+ ## Sessions: one task, one session, one tab group
12
+
13
+ Every tool takes a required `session`. Pick one name at the start of a task, in kebab-case, describing the **task** and not the site (`flight-compare`, not `united`). Reuse it for every call in that task, even across different sites. Switching names mid-task is the single most common cause of tabs scattering across groups.
14
+
15
+ On the **first** `navigate` of a task, also pass `group_title` — a short human-readable label in the user's language. Tell the user once that the task's pages are collected under that group and that you will close them whenever they ask.
16
+
17
+ Only call `close_session` when the user explicitly asks ("close those tabs"). Never close a group on your own initiative.
18
+
19
+ ## The loop
20
+
21
+ 1. `navigate` to open a page (`newTab: true` when pages must coexist).
22
+ 2. `snapshot` to read it. This returns an accessibility outline, not HTML — one element per line, indented by nesting:
23
+
24
+ ```
25
+ heading "Sign in"
26
+ @e46 textbox "Email" val="you@example.com"
27
+ @e50 checkbox "Remember me" [x]
28
+ @e51 button "Sign in"
29
+ ```
30
+
31
+ A leading `@e…` is the ref you pass to `click`/`fill`; a line without one is context you can read but not act on. Trailing markers: `[x]`/`[ ]`/`[/]` for checked, unchecked, and mixed, `*` focused, `-` disabled, `×N` for N identical rows collapsed into one.
32
+ 3. Act with `click` / `fill` using an `@e` ref from that snapshot.
33
+ 4. `snapshot` again to confirm the result before the next action.
34
+
35
+ Take a fresh snapshot after anything that changes the page. A ref names the underlying element rather than its position in the outline, so it usually survives an unrelated change — but re-reading is what tells you the action worked.
36
+
37
+ ### Snapshot options
38
+
39
+ - `mode` — `interactive` (default) keeps the controls plus headings, table cells, and images. `full` keeps every node, for when the default has filtered away something you needed. `text` keeps prose and issues no refs.
40
+ - `maxTokens` — ceiling on the outline, 2000 by default. Output stops at the last element that fits and ends with `# truncated: N more nodes`; when you see that, narrow the page (scroll, open a subsection) rather than raising the ceiling.
41
+ - `maxDepth` — drop elements nested deeper than this, to see a large page's shape first.
42
+ - `diff: true` — mark what changed since this tab's previous snapshot: `[+]` added, `[~]` changed, and a trailing `# removed:` line. Every current element is still listed, so all refs stay usable. Use it after an action on a page whose bulk does not change.
43
+
44
+ On a large page where you want one specific control, `find` is cheaper than a snapshot: give it a plain-language query ("sign in button") and it returns the best-matching `@e` refs. It adds to the ref table rather than replacing it, so refs from your last snapshot stay usable. Use `snapshot` when you need the page's structure, `find` when you already know what you are looking for.
45
+
46
+ ## Prefer @e refs over CSS selectors
47
+
48
+ `@e` refs identify the element itself, so they survive both the hashed class names of modern frameworks and unrelated edits elsewhere on the page. Reach for a CSS selector only when the target has no ref, and for `evaluate` only when you need something the snapshot cannot express — an attribute like `href`, a scroll, or a complex event sequence.
49
+
50
+ ## Text input
51
+
52
+ `fill` handles `<input>`, `<textarea>`, and `contenteditable` rich editors (ProseMirror, Lexical, Slate, Quill), firing the input events those editors listen for. It is **clear-and-insert**: existing content is replaced. To append, read the current value with `evaluate`, concatenate, then `fill` the result.
53
+
54
+ ## Dropdowns and focus
55
+
56
+ `fill` cannot drive a native `<select>`. Use `select` instead: it matches the option's `value` first, then its exact visible text, then a substring, so both `"CA"` and `"California"` can work. When nothing matches, the error lists the available options — read it rather than guessing again.
57
+
58
+ `focus` gives an element keyboard focus without clicking it, for fields that react badly to a click (date pickers that open a popup, inputs that select-all). Follow it with `key_type` or `send_keys`.
59
+
60
+ ## Waiting
61
+
62
+ Prefer `wait_for_selector`: it polls until an element is visible, or gone with `state: "hidden"`, and returns the moment the condition holds. `navigate` already waits for load, so do not add a blind wait after it.
63
+
64
+ `wait` is a fixed sleep capped at 3000ms. Reach for it only when there is nothing to wait *for* — a settling animation, a debounce — never as a substitute for `wait_for_selector`.
65
+
66
+ ## Scrolling
67
+
68
+ `scroll` takes a `direction` ("down" by default) with optional `pixels`, or an explicit `deltaY`. It reports `moved` and `atEnd`, so you can tell an infinite feed from the bottom of the page instead of scrolling blindly. Pass a `selector` to scroll one pane rather than the window.
69
+
70
+ `scroll_into_view` scrolls an element into view and returns its geometry. Call it before `mouse_click` or `hover`, whose coordinates only mean anything for a visible element.
71
+
72
+ ## Reading page text
73
+
74
+ To read an article, use `get_text`, not `snapshot`: a snapshot describes structure and controls, while `get_text` returns prose. By default it strips navigation, headers, footers, sidebars, and cookie banners. If that removes something you needed, retry with `raw: true` for the body's verbatim `innerText`. Output is capped (`maxChars`, default 20000) and reports `truncated`.
75
+
76
+ ## Network and dialogs
77
+
78
+ `network` lists the tab's requests with method, URL, status, and a `requestId`; filter by `method`, `status`, or a URL substring. Use it to find the API call behind a page, or to see why something failed. `network_detail` returns one request's headers, and with `body: true` its response body.
79
+
80
+ Requests are only recorded from the moment the tools attached to that tab, so a request made during the very first `navigate` may be missing — reload if you need it.
81
+
82
+ `dialog` answers a native `alert`, `confirm`, or `prompt` with `action: "accept"` or `"dismiss"`, passing `text` for a prompt. **An open dialog blocks every other tool on that tab** — if calls suddenly start timing out after a click, an unanswered dialog is the likeliest cause. Call it only once a dialog is actually open; it errors when there is none.
83
+
84
+ ## Submitting and special keys
85
+
86
+ Prefer clicking the submit button with `click`. When there is no button, use `send_keys` with `Enter`. `send_keys` also takes chords such as `Control+A` or a bare `Escape` to dismiss a modal.
87
+
88
+ ## When a page ignores clicks
89
+
90
+ Some pages (banking portals, captchas) check `event.isTrusted` and ignore the synthetic events `click` and `fill` produce. For those, use the trusted-input tools, which generate real browser input:
91
+
92
+ - `mouse_click` — click at viewport coordinates. Get coordinates from a `screenshot`, from `scroll_into_view`, or from `evaluate` returning `getBoundingClientRect()`.
93
+ - `hover` — move the pointer over an element or point, for menus and tooltips that only open on hover.
94
+ - `key_type` — type into whatever is focused.
95
+ - `send_keys` — press one key or chord.
96
+
97
+ Reach for these only after a normal `click` or `fill` has failed; coordinates are far more brittle than `@e` refs.
98
+
99
+ ## Screenshots
100
+
101
+ `screenshot` returns the image directly, so you can simply look at it. Pass `selector` to capture one element, or `format: "jpeg"` with `quality` to shrink a large capture.
102
+ prefer snapshot over screenshot use screenshot when snapshot not work.
103
+
104
+ ## Evaluate tips
105
+
106
+ - Return compact data. Use `JSON.stringify(value)` without indentation — pretty-printing inflates a large result until it is truncated.
107
+ - Calls share the page's JavaScript realm, so re-declaring the same `const` twice throws. Wrap each call's body in an IIFE: `(() => { const x = 1; return x })()`.
108
+
109
+ ## Reading the user's open tab
110
+
111
+ To act on a page the user is already looking at ("the invoice I have open"), call `find_tab` with `active: true`. That borrows the tab they are viewing rather than opening a new one. Otherwise `find_tab` takes a full `url` and searches this session's own tabs.
112
+
113
+ ## Known limits
114
+
115
+ - **Cross-origin iframes**: tools act on the top frame. If the target lives in an iframe from another origin, navigate directly to the iframe's URL.
116
+ - **Trusted input activates the tab.** `hover`, `mouse_click`, `key_type`, and `send_keys` bring their tab to the foreground, because Chrome only delivers real input to a focused tab. The DOM-level tools work fine on a hidden tab.
117
+ - **Network history starts at attach.** `network` cannot show requests made before the tools attached to that tab.
118
+ - **A debugging banner is normal.** Chrome shows a "being debugged" notice while these tools are attached, and DevTools cannot be open on the same tab at the same time. This is how the bridge works, not a fault — mention it if the user is surprised.
119
+
120
+ ## When a tool fails
121
+
122
+ The error text names the fix. Two cases matter:
123
+
124
+ - **"No Chrome extension is attached"** — Chrome is closed, the extension is not loaded, or its popup toggle is off. Ask the user to open Chrome, load the extension at `chrome://extensions` (Developer mode → Load unpacked), and check the toggle in its popup. Do not try to install it yourself; loading an unpacked extension is the browser owner's decision.
125
+ - **The tools are missing entirely** — the daemon is not running. It normally starts with the harness; if it did not, tell the user to run `~/.dsh-chrome/bin/chrome-daemon start`. Never run `stop` or `restart` on your own.