Build a CDP Plugin for Webpage Debugging: Inside the DSH Plugin System
An agent that can only read files cannot debug a web page. This is the anatomy of a real plugin that drives a visible Chrome over the DevTools Protocol — the ref engine, the Overlay picker, the one-browser guarantees, and the four failure modes nobody mentions in the tutorial.
TL;DR
A DSH plugin is one package with two halves: a host half that contributes tools to the agent, and an optional client half that contributes React UI to the shell; they talk over a package-private RPC channel. Driving Chrome over CDP instead of Playwright buys the DevTools Overlay picker, a zero-dependency install, and a real persistent profile. Three ideas carry the implementation: a ref table backed by real DOM node handles, real input events at coordinates with an honest report of what was topmost, and one browser by construction. The parts that actually cost time are elsewhere: Chrome 136+ refusing the DevTools port on the default profile, scroll restoration that Chrome does not do for Page.navigate, and ESM symlink resolution that breaks every nested import.
LogNroll Team
Engineering & Product
A build log for dsh-plugin-browser-cdp: how a plugin package is put together, how a DevTools connection is driven without a browser-automation dependency, and what it takes to turn a browser into something an agent can debug a page with.
The gap an agent cannot cross
Ask a coding agent to fix a page and it will read the source, find the component, and produce a patch that is syntactically perfect and occasionally wrong. It cannot see that the dropdown is covered by a sticky header, that the button rendered at zero height because a flex parent collapsed, that the click handler never fired because an overlay swallowed the event, or that the API call returned 200 with an error body. Source code describes intent. The rendered page describes what happened.
Removing that gap means giving the agent a browser. Not a headless engine behind a proxy object, and not a screenshot it has to interpret — an actual browser it can query, click, and point at. That is what dsh-plugin-browser-cdp is: a DSH plugin that drives a real, visible Chrome over the Chrome DevTools Protocol, and whose headline capability is the one thing a screenshot cannot give you, a precise, reusable reference to an element the human just pointed at.
Everything below is drawn from the plugin’s own source, because the interesting parts of a browser plugin are not the happy path. They are the failure modes: the profile lock that makes a launch silently futile, the scroll position Chrome restores for a reload but not for a protocol-driven navigation, the shadow root that querySelectorAll cannot reach, and the import that works in your editor and fails in the profile.
The shape of the thing
One package, two halves, one browser. The host half holds the DevTools connection and thirteen tools; the client half adds two buttons to the composer; the browser is a real Chrome with a persistent profile, so the pages being debugged are the pages you are actually logged into.
What the plugin exposes
Thirteen tools, grouped by the intent behind them rather than by the CDP domain they happen to use. The grouping matters: an agent choosing a tool is answering “what am I trying to learn”, not “which protocol method do I want”.
See the page
browser_snapshotA compact outline of interactive controls, headings and text blocks, each tagged with a ref such as e12. Hidden elements are skipped by default and the header says how many were skipped.
browser_readThe page text, for reading rather than interacting — the cheap way to answer “what does this page say” without spending refs on it.
browser_inspectEverything about one element: unique CSS selector, XPath, attributes, geometry, computed style, and whether it lives inside a shadow root.
Reference something
browser_pickArms the DevTools picker in the visible browser. The user hovers (the native overlay highlights and labels elements) and clicks; the pick returns as a reusable ref plus selector, XPath, attributes and style. count collects several at once.
Composer buttonsThe zero-typing route: Open browser brings the driven Chrome forward, Pick element writes the reference straight into the composer draft. Both are the plugin’s client half.
Act
browser_navigateOpen a URL, or go back, forward, or reload — with the reader’s scroll position carried across loads.
browser_clickReal mouse events at the element’s on-screen position, reporting what is actually topmost at that point, so a click blocked by an overlay is visible instead of silently wrong.
browser_type / browser_press / browser_selectFocus a field and type into it, send named keys and chords (Enter, Tab, Control+a), or choose an option in a real select element.
browser_waitWait for a selector, a piece of text, or a URL to appear — or to vanish — instead of re-snapshotting in a loop.
Inspect and control
browser_tabsList, open, close, or focus tabs; it also reports whether Chrome was launched by the plugin or was already running and merely attached to.
browser_evalEvaluate JavaScript in the page — the escape hatch for everything the structured tools do not cover.
browser_screenshotCapture the viewport, or the whole scrollable page, to a PNG inside the workspace so it can be read back and reasoned about.
The pick flow is the part worth reading twice, because it is the only one where the human is the sensor. The agent arms the picker; the user hovers and clicks in a browser they can see; what comes back is not a screenshot but a reference:
Picked element reference page: https://example.com/pricing ref: p1 selector: #plan-toggle xpath: /html/body[1]/main[1]/section[2]/button[1] tag: button role=button name="Compare plans" text: "Compare plans"
The ref is what every action tool accepts. The selector and XPath are for the human, who will paste them into DevTools or a test. And note what is not in that block: no coordinates, which would go stale on the next layout change; no screenshot, which the agent would have to interpret; no DOM dump, which would blow the context window on a real page.
One package, two halves
The DSH plugin system’s central design decision is that a plugin is a single npm package that may contribute to both sides of the application at once: the Node process where the agent runs, and the browser where the user types. Those are different runtimes with different module systems, and the plugin system does not pretend otherwise — it just lets one package own both.
Host half
lib/index.js
A Cordis plugin running in the DSH profile process. It declares inject = [tools, systemPrompt], resolves configuration, opens the DevTools socket, and contributes thirteen tools to the agent. Everything that touches Chrome lives here.
Client half
lib/client.js
A browser bundle the shell serves at /plugins/<name>/client.js, declared through dsh.client in package.json. It registers into the conversation.input.left slot and renders the two composer buttons. It never talks to Chrome — it asks the host half over RPC.
The client half is declared in package.json, and that single block is the whole registration story — the shell serves the bundle, and the listed packages are what it is allowed to import at runtime:
{
"name": "dsh-plugin-browser-cdp",
"main": "lib/index.js",
"exports": {
".": { "default": "./lib/index.js" },
"./client": { "default": "./lib/client.js" }
},
"dsh": {
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-connection",
"@deepseek-ai/dsh-client-ui-conversation"
]
}
}
}Installing it is two steps: link the package where the profile resolves bare plugin names, and add one entry to the profile’s patch layer.
# 1. Link the package into the profile's module tree.
ln -s /path/to/dsh-plugin-browser-cdp \
~/.dsh/profiles/node_modules/dsh-plugin-browser-cdp
# 2. Add to ~/.dsh/profiles/web/cordis.patch.yml
- insert:
- id: browser-cdp
name: dsh-plugin-browser-cdpTwo operational details are worth knowing before you plan a debugging session around it. Editing the patch entry is picked up live by the profile’s patch-layer watcher, so enabling the plugin needs no restart. Editing the plugin’s own files is not watched — they live outside the profile and the module is already in Node’s ESM cache, so a source change needs a restart. Restarting closes the DevTools socket but leaves Chrome and its profile running, so your tabs and logins survive it.
Why the two halves talk over RPC
The picker acts on a different browser than the page the button is drawn in. The client calls connection.rpc.call('/browser-cdp', 'pick', ...) and the host registers the same channel with authority: 'loopback', matching how the web GUI is reached. That authority value is not decoration: it means the same-origin POST passes the connection’s browser-trust fence, which is the same fence that defends the route against DNS rebinding.
Why CDP and not Playwright
This is the decision that shapes everything else, and it was made against constraints rather than preferences: what the plugin is allowed to install into a profile, and what it must be able to do that a normal automation library cannot.
Playwright / Puppeteer
Rejected as the primary engineThe profile resolves no browser-automation package, so this means installing a package plus a second bundled browser just to drive the Chrome already on the machine. Worse, Playwright has no equivalent of the DevTools element picker, so the headline capability would still need a raw CDP session bolted on.
A Chrome extension
RejectedA content script can see the DOM, but it needs a messaging bridge and a native host to reach the agent, cannot install or drive itself, and cannot capture the protocol-level data (network, console, screenshots, layout) that CDP gives for free.
Chrome over CDP
ChosenZero dependencies beyond a WebSocket client the package implements itself. It drives the browser the user can actually see, keeps a persistent profile so logins survive, and exposes the Overlay domain — the exact feature that makes element picking possible.
Playwright’s usual advantages — auto-waiting, resilient locators, a rich test API — are real, and if you are writing a test suite you should probably use it. But they solve a different problem. A test needs to assert things a developer already knows how to name. An agent needs to discover what is on the page and then refer to it reliably, which is a reference problem, not a locating problem. So the plugin replaces Playwright’s locator strategy with something better suited to it: a ref table backed by real DOM node handles, plus generated unique selectors and XPaths for the human.
The cost you are accepting
CDP is a lower-level protocol, so you own the waiting, the retries and the geometry. That cost is visible in the code: roughly five hundred lines for the connection, seven hundred for the ref engine, five hundred for actions. Nothing here is free — it is just spent in a different place than a dependency would spend it.
Anatomy of a CDP client
The protocol is simpler than its reputation, provided you use it the way it is meant to be used: one browser-level socket, with every page multiplexed over it as a flattened session.
1. Find the endpoint
GET http://127.0.0.1:9222/json/version returns the browser version and, crucially, webSocketDebuggerUrl. If nothing answers, no Chrome is listening and the plugin has to launch one.
2. Open one socket
The browser-level WebSocket is the only connection. Pages are not separate sockets; they are sessions multiplexed over this one, which is what keeps target discovery and page control consistent.
3. Attach with flatten
Target.attachToTarget with flatten: true returns a sessionId. Session-scoped commands then carry that sessionId, and events come back tagged with the sessionId that produced them.
4. Multiplex by id
Every command gets an incrementing id; replies resolve the matching promise from a pending map; events go to listeners. That is the whole dispatch layer — about forty lines.
5. Keep a target registry
Target.setDiscoverTargets tells the connection when tabs appear, close, or navigate, so the page list is live rather than a snapshot taken once at connect time.
6. Fail loudly
A closed socket rejects every in-flight command with a distinct error type, so the agent reads “the DevTools connection is closed” instead of waiting out thirteen separate timeouts.
The dispatch layer is the part people expect to be hard and is not. One incrementing id, one pending map, one listener set — and the one thing that is easy to get wrong, which is what happens to in-flight commands when the socket goes away:
#nextId = 0
#pending = new Map() // id -> { resolve, reject, timer }
/** Send one protocol command and await its reply. */
send(method, params = {}, sessionId) {
if (!this.connected) throw new CdpDisconnectedError(this.closeReason ?? undefined)
const id = ++this.#nextId
const message = { id, method, params }
// Session-scoped commands carry the sessionId; browser-level ones omit it.
if (sessionId !== undefined) message.sessionId = sessionId
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.#pending.delete(id)
reject(new CdpError('timed out', -1, method))
}, COMMAND_TIMEOUT_MS)
this.#pending.set(id, { resolve, reject, timer })
this.#ws.send(JSON.stringify(message))
})
}
/** Replies, protocol errors and events all arrive on the same socket. */
#onMessage(text) {
const message = JSON.parse(text)
if (typeof message.id === 'number') {
const entry = this.#pending.get(message.id)
if (entry === undefined) return
this.#pending.delete(message.id)
clearTimeout(entry.timer)
if (message.error !== undefined) entry.reject(new CdpError(message.error.message, message.error.code, method))
else entry.resolve(message.result)
return
}
// No id means an event, tagged with the session that produced it.
for (const listener of this.#listeners) listener(message)
}Attaching a page is where “flattened” earns its keep. Without it you would open a second WebSocket per page; with it, one attach call returns a session id that you thread through every subsequent command, and events come back labelled with the page that caused them:
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true })
// From here every page-scoped command takes the session, and events
// arrive with the same sessionId attached.
await browser.send('Page.enable', {}, sessionId)
await browser.send('Runtime.enable', {}, sessionId)
await browser.send('Page.navigate', { url: 'https://example.com' }, sessionId)Two robustness details in the connection are worth copying into any CDP client you write. First, the connection asks for target discovery (Target.setDiscoverTargets) instead of enumerating targets once, so tabs that appear later — a new tab, a window.open, a session restore — are known rather than invisible. Second, it guarantees at least one page exists: Chrome opens its DevTools port slightly before it registers the page target for theabout:blank argument, and a user may have closed every tab, so a tool call would otherwise fail with “no page targets are available” on a browser that is perfectly healthy.
The ref engine: from DOM node to a stable handle
A ref is a short string — e12 from a snapshot, p1 from a pick — and every action tool accepts either. What makes that safe is that a ref is never resolved by re-parsing a string. It is resolved through a real DOM node handle, so it survives ordinary DOM churn and cannot silently match a different element.
A walker that pierces open shadow roots
The snapshot runs inside the page and walks the real tree, descending into open shadow roots thatdocument.querySelectorAll cannot reach. Elements worth exposing — interactive tags and roles, headings, and text blocks — get a ref and are stored in a page-local map. Rendered indentation shows logical nesting, so wrapper markup liketable > tbody > tr > td does not push real content off to the right.
Selectors computed against the element’s own root
Because a node inside a shadow tree is not addressable from the document, selectors are computed against el.getRootNode(). The generator tries the id first, then a short list of stable attributes, and only falls back to a structural path — checking uniqueness at every step so it never hands out a selector that matches two elements.
/** A selector that uniquely identifies `el` inside its own root node. */
function uniqueSelector(el) {
const scope = el.getRootNode() // document OR shadow root
const tag = el.tagName.toLowerCase()
if (el.id) {
const byId = '#' + cssEscapeIdent(el.id)
if (matchCount(scope, byId) === 1) return byId
}
for (const attr of ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'name',
'aria-label', 'placeholder', 'title', 'alt', 'type']) {
const value = el.getAttribute(attr)
if (!value) continue
const candidate = tag + '[' + attr + '="' + escapeAttr(value) + '"]'
if (matchCount(scope, candidate) === 1) return candidate
}
// Structural fallback, built one level at a time and only while unique.
// ...
}Two consequences fall out of that design, and both are user-visible. Refs accumulate rather than reset: taking another snapshot extends the ref table instead of replacing it, so looking again never invalidates a ref you already hold. And a dead ref reports itself: if the element was removed, the failure says so rather than quietly resolving to whatever now occupies that position.
The hidden-element problem, learned from Wikipedia
Hidden elements — display: none, zero size, aria-hidden — are excluded from snapshots by default, and the header says how many were skipped. That is not fussiness. Collapsed menus on real sites otherwise fill the entire snapshot and push the content you asked for past the limit; the mobile menu on Wikipedia is the case that forced the change.
Pass includeHidden: true when a hidden control really is the target — a CSS-only menu toggle, for instance, exists in the DOM long before it is visible.
Real input, and honest answers
The easiest way to build a browser tool is to evaluate element.click() in the page and call it a day. It is also the fastest way to build a tool that lies: script-level clicks fire handlers but skip hit-testing, so any answer about whether a control is actually usable becomes fiction.
So clicks are dispatched as real mouse events at the element’s on-screen position, after scrolling it into view — and, critically, the tool reports what was actually topmost at that point:
// Scroll it into view, then read its viewport-relative rect.
await browser.send('DOM.scrollIntoViewIfNeeded', { backendNodeId }, sessionId)
const rect = await elementRect(browser, sessionId, element) // getBoundingClientRect()
const cx = Math.round(rect.x + rect.w / 2)
const cy = Math.round(rect.y + rect.h / 2)
// Who is really on top at that point? An overlay here is the bug, not a mystery.
const host = await evaluate(`(() => { const el = document.elementFromPoint(${cx}, ${cy});
return el ? el.tagName.toLowerCase() + ... : null })()`, sessionId)
await browser.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: cx, y: cy, button: 'none' }, sessionId)
await browser.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: cx, y: cy, button, clickCount }, sessionId)
await browser.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: cx, y: cy, button, clickCount }, sessionId)
// topmostAtPoint is reported alongside the coordinates and the size.
return { at: { x: cx, y: cy }, size: { w: rect.w, h: rect.h }, topmostAtPoint: host }The geometry deliberately comes from getBoundingClientRect() rather than DOM.getBoxModel. Input coordinates must be viewport-relative, and the bounding rect is unambiguous about that, whereas the box model’s coordinate space depends on the frame it belongs to. One rule, one fewer class of off-by-a-scrollbar bug.
Zero-area targets — an icon-font node inside a button, a ::before pseudo-element carrier — cannot be clicked by coordinate at all. Those fall back to a script click, and the result says so, so “this control has no clickable area” remains a fact the caller can act on rather than being papered over.
Typing, keys and selects
Text goes in through a focus call plus Input.insertText, which is what a paste looks like to the page; named keys and chords go through Input.dispatchKeyEvent with the right modifiers, because Control+a has to be a key sequence, not a string; and a real<select> is driven through DOM.resolveNode plus a native setter and a change event. Framework-friendly in all three cases, and none of them are simulated at the DOM level in a way the app can detect and mishandle.
The picker is the point
Everything above is achievable with several tools. The picker is the reason this plugin exists in this form, because it is the one capability that structurally requires a real, visible browser: a human points at something, and the agent receives a machine-usable reference to it. No typing, no ambiguous description, no “the third button in the header”.
Mechanically it is the DevTools Overlay domain, which is to say it is the DevTools element picker, borrowed:
// Put the page into the same "inspect" state DevTools uses.
await browser.send('Overlay.enable', {}, sessionId)
await browser.send('Overlay.setInspectMode', {
mode: 'searchForNode',
highlightConfig: {
showInfo: true, // the native tag/size tooltip
showStyles: false,
showRulers: false,
contentColor: { r: 111, g: 168, b: 220, a: 0.5 },
paddingColor: { r: 147, g: 196, b: 125, a: 0.4 },
borderColor: { r: 255, g: 229, b: 153, a: 0.5 },
marginColor: { r: 246, g: 178, b: 107, a: 0.4 },
},
}, sessionId)
// The click arrives as an event carrying the chosen node.
browser.on('Overlay.inspectNodeRequested', ({ backendNodeId }) => resolvePick(backendNodeId))
browser.on('Overlay.inspectModeCanceled', () => resolveCancel()) // Esc in the browser
// Then the node becomes a ref, exactly like a snapshot ref.
const { object } = await browser.send('DOM.resolveNode', { backendNodeId }, sessionId)
// ...register in the page ref map under 'p1', and describe it.Because the highlight is drawn by the browser rather than injected into the page, the user sees the same affordance they already know from DevTools, and the page’s own styles and scripts are never mutated to show it. It also means a picked node is identified by backendNodeId — the protocol’s stable handle — rather than by anything the page could have faked.
The visible feedback during picking is a one-line instruction strip at the top of the page. It is a small piece of code and it produced the article’s favourite bug, because it is a masterclass in CSS inheritance working against you:
host.style.cssText = 'all: initial; position: fixed; z-index: 2147483647; ... pointer-events: none;'
// Inside the shadow root the bar resets everything...
bar.style.cssText = [
'all: initial',
// ...which puts pointer-events back to 'auto'. A descendant with 'auto'
// is STILL a hit-test target underneath a 'none' ancestor, so without the
// declaration below the strip swallows hover -- and would itself be the
// element the picker reports for anything underneath it.
'pointer-events: none',
].join(';')The strip removes itself after two seconds, because it sits over the page and an instruction should not outstay its usefulness; the DevTools hover highlight stays as the ongoing feedback. Clicking the composer button again cancels, and so does pressing Esc in the browser — both paths disarm the picker and remove the banner, since an armed picker left behind would hijack the user’s next click.
A pick is a promise about a person’s time
The picker blocks on a human, which makes it the only tool in the set that can fail because somebody walked away. The timeout is configurable and generous by default (pickTimeoutMs, two minutes); a wait is honest about being a wait. But the flow is designed so that nothing is lost if it times out: the reference is written into the composer draft the moment it is picked, and if inputActions is unavailable the client falls back to the clipboard, and failing that to the console. Losing a pick a user just made in another window is worse than a terse notice.
One browser, by construction
Agents are concurrent. Two tool calls can arrive in the same millisecond, a scheduled job can fire while a human is clicking a composer button, and every one of them wants a browser. The plugin’s answer is not best-effort locking but structure: four layers, each of which would be sufficient on a good day.
One port, one profile
A single configured port and a single persistent userDataDir. Nothing in the code ever launches a second browser on a different pair, so there is no path by which two Chromes end up under the plugin.
Attach before launch
Every entry point probes the port first and reuses whatever answers. Only an empty port leads to a launch — which is why a Chrome you started yourself, with the right flag, is adopted rather than duplicated.
A launch mutex
ensureChrome collapses concurrent calls for a port onto one promise. Without it, callers racing before any browser exists each spawn a Chrome; the losers immediately forward to the winner and exit, which reads as a failure.
Chrome’s own profile lock
Chrome permits one instance per --user-data-dir and enforces it with a SingletonLock symlink pointing at <hostname>-<pid>. That lock is also readable, which turns the one baffling failure mode into an accurate error message.
The fourth layer is the interesting one, because Chrome’s per-profile lock is not only a backstop — it is readable, and reading it converts the single most baffling failure in browser automation into a sentence a human can act on:
// Chrome permits one instance per --user-data-dir and enforces it with a
// SingletonLock symlink pointing at "<hostname>-<pid>".
export function readProfileOwner(userDataDir) {
try {
const raw = readlinkSync(join(userDataDir, 'SingletonLock'))
const dash = raw.lastIndexOf('-')
return { host: raw.slice(0, dash), pid: Number(raw.slice(dash + 1)) }
} catch {
return null
}
}With that, the plugin can tell the user something specific: a Chrome already owns this profile but is not exposing a DevTools port — you opened the automation profile by hand, or an earlier Chrome is still around. Launching in that situation is futile, because Chrome forwards the request to the existing process and exits 0 while the port never opens. Instead of waiting out a thirty-second launch timeout and reporting a generic failure, the plugin fails immediately and names the pid that owns the profile, plus the remedy: close that window, or start it yourself with --remote-debugging-port=<port> and the plugin will attach to it.
Chrome 136+ will not give you a DevTools port on your normal profile
This is not a plugin preference, it is a browser rule, and it is a deliberate security improvement: a page that can reach your debug port can drive your logged-in browser. So the dedicated profile is mandatory, and userDataDir always points somewhere other than your everyday Chrome data directory. The upside is worth stating plainly — your normal browser is never touched, and the automation profile keeps its own logins, which is exactly why debugging a staging site behind SSO works without a login dance on every run.
Keeping the reader’s place
Here is a bug report any agent-driven browser produces within an hour of real use: “I was reading halfway down a long page, the agent refreshed it, and I was back at the top.” The cause is a quiet asymmetry in Chrome. It restores scroll for a reload and for a history traversal by itself, but not for a plain Page.navigate — which is exactly how an agent refreshes or revisits a page.
| Navigation | Scroll after | Who handles it |
|---|---|---|
| reload | preserved | Chrome does it; the plugin leaves the position alone. |
| back / forward | preserved | Chrome restores it from the session history entry. |
| goto to the same URL | would be lost | A plain Page.navigate resets the document; the plugin restores the remembered offset. |
| goto away and back | would be lost | Restored from a per-URL memory, keyed without the fragment. |
| page-initiated navigation | would be lost | Captured on Page.frameRequestedNavigation before the new document commits. |
The whole design is one rule: restore only when the new document is at the very top. If the browser already positioned the page, or the URL carries a #fragment that asked for a specific spot, the plugin does nothing at all. That single condition is what keeps it from fighting Chrome’s own restoration or overriding an explicit anchor.
The details that took the most iterations are the ones a first implementation gets wrong in ways that only appear on real sites:
- Positions are keyed by URL without the fragment, because
#installis a position within a page, not a different page. - Restoration retries for about a second. A document is often too short to accept the offset when
loadfires — lazy images and deferred content arrive later — and a singlescrollTowould be silently clamped to the top. - A failed restore does not overwrite the memory. Recording the clamped top would erase the very position a later attempt should retry.
- The memory is bounded to 200 URLs, evicting the least recently used, so a long debugging session cannot grow it without limit.
And then there is the race. Navigations the page starts by itself — a link, a form, a self-reload — are caught by a load watcher that reads the outgoing document’s position as the navigation begins. That read can lose: the new document may commit before it is processed, and the reading is then the destination’s scroll, usually zero, which would be stamped onto the destination’s own URL and destroy the position the restore needs. Two things prevent it. The capture happens on Page.frameRequestedNavigation, which fires earlier thanframeStartedLoading; and any reading that already shows the destination URL is discarded rather than recorded. Navigations the plugin drives skip the watcher entirely, because they capture deterministically before issuing the navigation and restore explicitly afterwards.
Why this plugin imports nothing
There is not a single import of @deepseek-ai/* anywhere in the plugin’s lib/. That is not minimalism for its own sake; it is a consequence of how ESM resolves plugins installed into a profile.
A plugin installed through a symlink in the profile’s node_modules is reached via that symlink, but Node resolves ES module specifiers from a module’s real path. So import '@deepseek-ai/dsh-tools' inside the package resolves against the package’s own directory chain, not the profile’s, and fails with ERR_MODULE_NOT_FOUND — the loader’s bare-specifier hook covers the plugin entry point only, not its nested imports. Depending on the profile’s layout would also tie the plugin to internal package paths that move between releases, and would break any install that links the package rather than copying it.
The fix is a small local toolkit that reproduces the two things the plugin actually needed from the framework: schema-shaped tool definitions and argument validation. The observable behaviour mirrors the real defineTool — a parameter spec map compiled into an object-rooted JSON Schema with a required list, and execute validating arguments before running the body:
/** Compile a { property: spec } map into an object-rooted JSON Schema. */
export function parameterSpecToJsonSchema(spec) {
const properties = {}
const required = []
for (const [key, definition] of Object.entries(spec ?? {})) {
const { required: isRequired, ...rest } = definition ?? {}
properties[key] = rest
if (isRequired === true) required.push(key)
}
return {
type: 'object',
properties,
...(required.length > 0 ? { required } : {}),
}
}The same reasoning applies to configuration. The plugin deliberately does not declare a Config schema, because that would mean importing the profile’s schemastery. The loader passes configuration through untouched when a plugin declares none, so validation happens in the plugin, where the error message can name the offending key. The payoff is a package whose only runtime requirements are Node’s built-ins and a Chrome-family browser — which is also why it is easy to test in isolation.
The rule to take away
Do not reach into the host application’s internals from a plugin. Ask the running system for what you need — ctx.get('connection'), ctx.inject([...]), the services the loader actually wires — and reimplement the small conveniences you miss. The code you write is then yours, and the plugin keeps loading when the host moves its internals around.
Debugging a page with it
All of that machinery exists to answer ordinary questions. These are the six that come up most often, and the tool that answers each:
Reproduce a reported bug on a live page
Start with browser_snapshot to see the actual controls, then browser_eval for the application state the UI is not showing, then browser_click on the exact ref that triggers the bug. Because clicks are real input events dispatched at coordinates, a handler that is only reachable by a real pointer behaves the same way it does for a user.
Find out what an element really is
browser_inspect returns the unique selector, the XPath, the attributes, the computed style and shadow-root membership. When a selector you wrote in CSS fails in production, the difference between what you assumed the DOM looks like and what the browser reports is usually the bug.
Check layout without guessing
Geometry comes from the element’s own bounding rect, and browser_click reports the topmost element at the click point. A zero-height button, a control covered by a cookie banner, or a link hidden behind a sticky header all show up as facts rather than as a mysterious “the click did nothing”.
Watch a wait instead of sleeping
browser_wait blocks on a selector, text, or URL appearing or disappearing. That converts flaky “it works after a second” reasoning into an explicit condition, and the failure message says which condition never became true.
Capture what the user saw
browser_screenshot writes a PNG into the workspace, viewport or full page. It is the cheapest way to confirm a visual regression the agent cannot describe in words — and, unlike a DOM dump, it shows overlapping elements.
Keep a page as a regression harness
A ref you hold from a snapshot stays valid across later snapshots, and a selector the plugin reports is always one it can resolve itself. That makes a short sequence of snapshot, click, wait, inspect a reusable smoke test rather than a one-off probe.
A worked example, from a real session on a staging checkout — the bug was “the Apply coupon button does nothing”:
browser_navigate → https://staging.example.com/cart
browser_snapshot → finds the ref for the coupon input and the Apply button
browser_type → ref e41, value "SPRING10" (real insertText, React state updates)
browser_click → ref e52, returns:
{ at: { x: 812, y: 604 }, size: { w: 128, h: 44 },
topmostAtPoint: "div.cookie-banner" } ← the answer
browser_eval → getComputedStyle(document.querySelector('.cookie-banner')).zIndex → "9999"
browser_screenshot→ the banner overlapping the button, visible in the captureThe click succeeded at the protocol level — mouse events at the right coordinates — and the page did nothing, because a consent banner with a higher stacking context was on top. A script-level click() would have hit the button and reported success, sending the agent off to debug the coupon API for an hour. That difference, between executing an action and reporting what actually received it, is most of the value of driving a browser honestly.
Where session replay fits in
CDP is how you interrogate a page you know is broken. It is not how you find out that it is broken for real users — you cannot attach a debugger to a customer’s laptop. That is the division of labour between the two: a session replay tool tells you which sessions hit the dead click, on which page, with which console errors and failed requests attached, and then a CDP-driven browser reproduces it on demand, in a real Chrome, with the same hidden overlay in the way. Recorded evidence first, live interrogation second.
Testing a browser plugin for real
A plugin that drives Chrome cannot be tested with mocked protocol replies and still be trusted, because the bugs live in the parts a mock does not have: real compositing, real shadow roots, real timing. So the suite splits by what actually needs a browser.
| Suite | Needs Chrome | Covers |
|---|---|---|
| test/e2e.mjs | Yes | The real tools against a local fixture: registration, navigation, snapshots including shadow roots and hidden-element filtering, ref stability across snapshots, typing and clicking that must change page state, async waits, inspection, tabs, screenshots, error paths, and the picker. |
| test/client.mjs | No | The composer buttons, by evaluating the real bundle against a stubbed module loader — so it tests the shipped artifact rather than a copy of its logic. |
| test/single-instance.mjs | Yes | The one-browser guarantee: concurrent first calls launching exactly once, later calls attaching, the profile owner staying the same process, and a locked profile being refused rather than fought over. |
| test/scroll.mjs | No | The scroll memory’s pure parts: URL keys, clamping, rounding, the size bound, and degenerate input. |
Two techniques in there are worth stealing. The picker is exercised by dispatching a genuine click into the session that armed it, because that is what the user’s mouse does — a synthetic call to the pick handler would test everything except the part that can break. And the client bundle is loaded the way the shell loads it: evaluate the bundle against a stubbed window.__ModuleLoader__, then call the registered factory with a require bound to React.
The React trap that eats an afternoon
Resolve React from the profile, and pair it with the react-dom that actually belongs to it. A profile tree can contain a nested react-dom with its own React copy, and mixing the two makes every element render as an opaque object instead of markup. There is no error message for this, just a component that returns something that is not a component.
Configuration and limits
Every key is optional, and the defaults are chosen so that the common case — a developer on a laptop debugging a staging site — needs no configuration at all.
| Key | Default | Meaning |
|---|---|---|
| port | 9222 | Chrome remote-debugging port. |
| chromePath | auto-detected | Chrome/Chromium/Brave/Edge executable. Also CHROME_PATH or DSH_CHROME_PATH. |
| userDataDir | <DSH_HOME>/browser-cdp-profile | Persistent profile. Logins survive here. |
| headless | false | Run Chrome headless. Picking needs a visible window, so leave this off for browser_pick. |
| extraArgs | [] | Extra Chrome flags, such as --no-sandbox in containers. |
| launchTimeoutMs | 30000 | How long to wait for the DevTools port to open. |
| screenshotDir | <cwd>/.browser-cdp-shots | Where captures are written before being read back. |
| pickTimeoutMs | 120000 | How long browser_pick waits for a human to click. |
| snapshotLimit | 400 | Maximum elements in one snapshot. |
| defaultTimeoutMs | 30000 | Default budget for waits and commands. |
| preserveScroll | true | Carry the reader’s position across loads. |
The environment variables matter for containers and CI: CHROME_PATH (or DSH_CHROME_PATH) overrides browser discovery, and CHROME_EXTRA_ARGS="--no-sandbox" is the concession for environments where Chrome’s own sandbox cannot run. That one is a test-environment escape hatch and never a default — turning off the browser sandbox to make an automated browser work is how a debugging tool becomes a security incident.
Cross-origin iframes are not traversed
A ref inside one fails with a clear message rather than a wrong answer. Reaching into a third-party frame would need a separate attach per frame; a confusing failure is worse than an honest one.
Picking needs a visible window
Headless Chrome has nothing to point at. The picker is the one capability that structurally requires a real screen, which is also the reason the plugin exists in this form.
Closed shadow roots stay closed
Open shadow roots are pierced by the walker. A closed root exposes no handle, so its internals are unreachable by design — the same boundary the page author chose.
A dedicated profile is mandatory
Chrome 136+ refuses to open the DevTools port on the default profile, so userDataDir always points somewhere other than your everyday Chrome data directory. Your normal browser is never touched.
What to copy if you build your own
The plugin is roughly four thousand eight hundred lines across ten files, and the interesting thing is how little of it is protocol code. If you are building something similar, these are the decisions that paid for themselves:
- Refs, not selectors, as the agent-facing handle. Generate selectors for humans; resolve refs through real node handles for machines. Refs that survive a later snapshot remove an entire class of “the element moved” failures.
- Real events at real coordinates, plus a topmost report. Any tool that acts on a page should state what actually received the action. Without that, a tool that cannot see an overlay confidently reports success.
- Two halves, one RPC channel, loopback authority. Let the UI ask the host to do privileged work instead of duplicating capability in the browser, and use the host’s own trust fence rather than inventing an auth scheme.
- Structure over locking for singleton resources. One port, one profile, attach-before-launch, a launch mutex, and an honest read of the browser’s own lock. Concurrency bugs in this area look like random failures and are miserable to debug later.
- Zero host imports. Reimplement the two helpers you need. Your plugin then survives host releases, works when linked rather than copied, and can be tested without the host present.
- Test against real Chrome, including the ugly paths. The picker cancel path, a refused launch on a locked profile, a document that grows after load, an overlay swallowing a click — those are the tests that find bugs.
Conclusion
A plugin system earns its keep when it lets one package span both sides of an application without pretending the two sides are the same. This plugin is a fair test of that: a host half that owns a DevTools connection and thirteen tools, a client half that owns two buttons in the composer, and a narrow RPC channel between them. The plugin system handles the wiring; the interesting engineering is all inside.
And that inside work is mostly about honesty. The ref engine exists so that “the button” becomes a handle that cannot drift. Real input events exist so that a click means what it means for a user. The topmost-element report exists so that a silent overlay is a fact rather than a mystery. The scroll memory exists so that the agent’s refresh does not cost you your place. None of it is clever; all of it is what separates a browser tool you can debug with from one that tells you a comfortable story while your production page stays broken.