DSH HUB
首页插件商店插件包社区排行榜资源发布指南
插件源码
返回插件目录

guanhaisen /

guanhaisen/dsh-agent-browser

已验证

DSH plugin that lets the model drive the right-Sidebar browser: read pages, click, type, navigate, switch phone/tablet/desktop viewport sizes, and screenshot. Desktop only; never touches app.asar.

★ 0 Stars0 Forks0 IssuesN/A 社区评分0 已确认安装
查看 GitHub
README来源: master@063465a7

dsh-agent-browser

English | 中文

Let the model actually use the DSH right-Sidebar browser: read a page's text, click and type in it, navigate, size the viewport, and take screenshots — from ordinary tool calls in the conversation.

It also adds a phone / tablet / desktop / custom viewport switcher, so you can see how a site renders at another size without leaving the app.

browser_navigate { url: "https://example.com" }
browser_read     { mode: "elements" }     -> refs e1, e2, …
browser_act      { action: "type", ref: "e1", text: "hello" }
browser_act      { action: "click", ref: "e2" }
browser_screenshot {}                     -> see the layout
browser_viewport { preset: "phone" }      -> re-render at 390×844

Desktop only. The tools drive an Electron <webview> guest. In Web profiles the Sidebar browser is a sandboxed cross-origin <iframe>, whose DOM cannot be read at all, so the tools answer with an explicit reason naming the Web profile instead of pretending to work. browser_tabs still lists those tabs — as mounted: true with carrier: "iframe" — so you can see that the tab exists and why it cannot be driven.

Third-party, non-invasive plugin. It mounts from your DSH profile, never touches app.asar, and leaves the built-in browser exactly as shipped.

  • Install — one command, or the Plugins page
  • Tools — what the model can call
  • Which size to use — pick desktop or phone, not the default
  • Viewport sizes — the UI side
  • Known limitations — read before filing an issue

Tools

Tool What it does
browser_tabs List this Session's browser tabs: id, title, url, loading state, whether the load failed (failed), whether the identity was inferred (unverified), whether each is mounted and drivable, which is active, and its carrier (webview on Desktop, iframe on Web).
browser_read Read a tab: mode: 'text' (default) for visible text, 'elements' for the interactive elements you can act on, 'html' for page HTML. Check failed first — a failed load reads as empty. truncated means the element cap was reached — hidden elements are filtered out silently and never counted as truncation.
browser_act click, type, press, scroll, select, reload, or wait. Target an element by the ref from a 'elements' read, or by a CSS selector. reload takes ignoreCache: true to bypass the cache. wait with a selector becomes a real wait — see Waiting for the page below.
browser_screenshot Capture the visible viewport as an image block the model can see. Pass a preset to size and capture in one call; the result reports the size it actually used and whether the page was confirmed to have reflowed (confirmed). A failed page is refused rather than captured.
browser_navigate Open a URL in a new tab, or navigate an existing one.
browser_viewport Read or change the tab's viewport size: auto, phone (390×844), tablet (834×1112), desktop (1280×800) or custom. The page reflows for real, so its own responsive breakpoints apply. Omit tabId to target the only mounted tab; the read always reports the tab it resolved. custom appears only for the custom preset.
browser_close Close one tab of this Session, so tabs opened while investigating something can be cleaned up. Closure is confirmed before the call reports success.
browser_eval Run JavaScript in the page — the only way to read layout geometry (getBoundingClientRect, scrollHeight, getComputedStyle). Always listed; refuses until enableEval: true (see configuration), so the capability is discoverable instead of unguessable. An expression returns its value; a statement body returns what it explicitly returns.

A typical loop:

browser_navigate { url: "https://example.com" }
browser_read     { mode: "elements" }          -> refs e1, e2, …
browser_act      { action: "type", ref: "e1", text: "hello" }
browser_act      { action: "click", ref: "e2" }
browser_read     { mode: "text" }              -> confirm what changed
browser_screenshot { preset: "desktop" }       -> see the real desktop layout
browser_screenshot { preset: "phone" }         -> and the 390px-wide one
browser_close    { tabId: "tab4" }             -> tidy up when finished

tabId may be omitted whenever the Session has exactly one mounted browser tab. With several, call browser_tabs first and name the one you mean.

Reading a failed page

Three fields describe three different things, and none of them can be inferred from the others:

Field Means
mounted The tab is in the DOM and could be driven — not that it loaded.
loading Electron says a navigation is in flight. A failed load is not loading.
failed The tab is on Chrome's network-error page, so there is no site content.

So mounted: true, loading: false, failed: true is a completely ordinary combination: a tab that is open and finished failing. Before failed existed, a page that never loaded looked identical to a healthy one, and the only way to tell was to read it and notice the text was empty.

unverified: true is the other half: the page could not be asked anything (its renderer is wedged, or the document is unreachable), so url and title are the last navigation's, not the current document's. Treat them as a hint, not a fact.

Both matter for screenshots, which is where the confusion cost the most: a capture is refused when either is set. A failed load paints Chrome's error page, and a wedged renderer paints a blank surface — either way the image is not the site, and returning it as a successful capture is how a page that never loaded gets mistaken for a page that "did not render". Note that failed cannot even be evaluated when the page is unscriptable, so unverified is doing real work rather than restating it.

So unverified is deliberately not just a warning label: it is the condition that makes a capture unverifiable. Inside a capture the identity probe is given more patience than elsewhere (up to 4 s) precisely so a genuinely heavy page — the several-iframes case a 15 s budget exists for — is not refused merely for being briefly busy.

Note that this is about transport failures only. An HTTP error the site itself rendered — a 404 page — is a real document and stays fully readable and screenshotable.

Waiting for the page

browser_act { action: "wait" } has two forms, and the difference matters on any page a framework renders.

A fixed sleep — { action: "wait", timeoutMs: 2500 } — sleeps and returns. Rarely what you want, for a reason worth stating: the plugin's own notion of "the page has loaded" is Electron's navigation state, which a single-page app clears as soon as its HTML and scripts arrive, long before React (or anything else) has mounted and painted. So the page reports itself loaded while the screen is still empty, and a sleep is a guess that is too short on a slow machine and wasted on a fast one. A report measured exactly that: a 2500 ms guess in a loop that ran ten times.

A named condition waits for something observable instead:

browser_act { action: "wait", selector: ".todo-list", state: "visible" }
  • state: "visible" (the default) — the element is drawn. This is what "the app has rendered" usually means.
  • state: "attached" — the element merely exists. Weaker, and worth knowing about: an element can exist while display: none. A report counted hidden rows as present and concluded a layout had no overflow, so the two are kept distinct.
  • state: "hidden" — satisfied by absent as well as invisible, so it works for waiting out a spinner that gets removed rather than hidden.

Omit timeoutMs and the wait uses the rest of the call's own budget (still bounded, never longer than the call).

The condition is polled inside the page, so one tool call watches for it instead of a loop of calls and results. The poll uses setTimeout rather than requestAnimationFrame deliberately: rAF does not fire while a document is hidden, and a wait that silently never completes in a background tab would be a worse trap than a coarse poll.

A condition that never holds fails. It does not return ok: true with a matched: false you might skim past — the call errors, naming the selector, the state and how long it waited. Naming a condition is a way to trust the answer, so a timeout returned as success would defeat the point. An invalid selector is reported as invalid CSS immediately rather than after burning the budget, because that is a caller mistake and not the page's fault.

Which size to use

Do not screenshot at the default. The default viewport is auto, which is the Sidebar column width — typically 400–700 CSS px. That is not a device anybody uses: it fires breakpoints no real visitor sees, so a screenshot taken there misrepresents the page. Pick a size on purpose:

Task Use Size
Browsing or reviewing an ordinary site preset: "desktop" 1280×800
Designing, fixing or checking a mobile page or layout preset: "phone" 390×844
Checking the middle breakpoint preset: "tablet" 834×1112
Something specific (a 1440 laptop, a 1024 tablet) preset: "custom" your numbers

The tool descriptions say this too, so the model reaches for it without being told. Two ways to act on it:

  • Screenshot directly at a size — browser_screenshot { preset: "phone" } applies the size and captures in one call. Prefer this: it cannot be forgotten between two calls.
  • Set it once, then keep capturing — browser_viewport { preset: "desktop" } persists on that tab, and later browser_screenshot calls without a preset inherit it.

Every screenshot result reports the size it actually captured (preset, pageWidth, pageHeight, scale), so a capture taken at an unintended size is visible in the answer rather than silently misleading.

Three of those fields are easy to misread, and a report misread two of them, so they are worth stating plainly:

Field What it is
width / height The delivered image's pixels, capped by maxWidth (screenshotMaxWidth, 1568 by default). Not the viewport, and not a fixed output canvas.
pageWidth / pageHeight The viewport the page laid out at, in CSS pixels. This is the size the page's own media queries saw.
scale The display shrink inside the Sidebar column: scale === columnWidth / pageWidth.
columnWidth The column width scale is a ratio of, so the conversion never has to be reverse-engineered.

The misreading is worth recording because it was a coincidence: two different presets (desktop 1280×800 and custom 1440×900) both returned 1568×980, which looks exactly like a fixed 16:10 canvas. Both were 16:10 and both exceeded the 1568 cap, and the cap is the real explanation — 1568×800/1280 = 980 and 1568×900/1440 = 980. A phone capture (390×844) is well under the cap and is delivered at its own size. Likewise scale × pageWidth came out as the same 767 for both presets, which is simply the column width, not a conversion factor for the image.

When a size was requested, the result also carries confirmed. Resizing writes styles, but layout is not synchronous with that write — measuring in the same turn reports the previous size. That is exactly how preset: "phone" once came back saying pageWidth: 1280: the number was real, it was just stale. The plugin now polls until the page agrees (within a pixel) or the confirmation budget runs out:

  • confirmed: true — the page was observed laying out at the requested size.
  • confirmed: false — it had not reflowed; pageWidth is what it was still using. Do not judge that breakpoint from this image.

browser_viewport reports confirmed the same way, and omits it for auto, which has no target width to confirm.

Recovering a stuck or stale tab

Three things go wrong often enough to be worth naming.

A screenshot that fails while the page reads fine. This is the confusing one, and the diagnosis is now in the error. keepMounted keeps every visited tab in the DOM so the tools can reach one you are not looking at — but only the SELECTED tab of the on-screen pane is drawn, and a hidden guest is not composited. A capture aimed at a hidden tab waits for a frame that never arrives, which is why the failure "sticks", why browser_read keeps working against the same tab, and why reloading does not help (it cannot change whether a tab is being drawn). The fix is to make the tab the drawn one:

Click this tab in the right Sidebar so it becomes the shown one, then retry.

Every failed capture now says which of these it was, from the host DOM: an ancestor is hidden (the Conversation is not on screen), the Sidebar is collapsed, another tab is the one being shown (named), or visibility is ruled out because the tab really is the drawn one. When the shell exposes none of those signals the diagnosis says nothing rather than guessing — "ruled out" is only claimed from evidence.

A page that has stopped answering. Every guest call is bounded by the plugin's own budget (6 s for a read or an action, 15 s for a screenshot — a capture forces a whole repaint and a heavy page legitimately needs longer). A stalled renderer produces an error saying so instead of a 60-second stall. Note what the budget does and does not buy you: it bounds how long the browser waits before giving up. The Inspect channel then has its own timeout, and when the Client reports a failure the registry keeps that as a diagnostic and does not settle the query — so the caller still waits out clientInspectTimeoutMs before being told. Both numbers appear in the message and neither is the cause; the plugin's own reason is the one to act on.

A change that appears not to have taken effect. A dev server that sends no cache headers — python -m http.server is the usual one — lets Chrome serve the old CSS and JS under heuristic caching, so fresh files look like they never applied. Numbers that "did not change" are the tell:

browser_act { action: "reload", ignoreCache: true }

The result reports ignoreCache, which is false if the carrier had no cache-bypassing reload, so a plain reload is never passed off as a bypass.

Tabs opened while investigating something used to accumulate with no way to remove them. They can now be closed:

browser_close { tabId: "tab4" }

Closure is confirmed against the tab list before success is reported, so a close the Sidebar ignored is an error rather than a false confirmation.

Viewport sizes (phone / desktop / custom)

A browser tab normally renders at the Sidebar column's width. Switching the viewport makes the page lay out at a real phone, tablet or desktop width instead, so a site's own media queries fire and you see the layout that size really produces. See Which size to use above for what to pick — the short version is that auto is the column and you almost never want it for a screenshot.

Three ways to drive it:

  • Right-click the tab — the tab strip has no "⋯" button. The kit opens a tab's menu on right-click (its onContextMenu handler), and that menu is where these items appear, under Viewport size. The current choice is ticked. With only one tab open the menu is otherwise just Close tab; these items are what make it worth opening.
  • Settings → General → Browser viewport — the default that new tabs inherit.
  • Shortcut — browser.viewport.cycle cycles fit → phone → tablet → desktop for the focused browser tab. It ships unbound so it never steals a chord; bind it yourself in Settings → Shortcuts and search for "viewport".

browser_viewport exposes the same control to the model. Reading it reports both the preset and the CSS pixel width the page is actually laying out at:

{ "tabId": "tab3", "preset": "phone", "pageWidth": 390, "pageHeight": 844,
  "targetWidth": 390, "scale": 0.62, "mounted": true }

The read resolves an implicit tab the same way the write does, so tabId is always reported — omitting it targets the Session's only browser tab, and naming a tab that does not exist is refused with the same error the other tools give.

How it works, and why not zoom

The guest is an Electron <webview>, and that element's box is the page's viewport: a 390px-wide element makes the document lay out at 390 CSS pixels. To show a viewport wider than the column, the element is scaled down for display only, inside a clipping container, and the scale is recomputed by a ResizeObserver when the Sidebar is resized. The page's layout stays truthful; only the presentation shrinks. pageWidth in the read-back is the guest's own window.innerWidth, so it always reports what the page believes.

<webview>.setZoomFactor() is deliberately not used. Chromium's zoom policy is same-origin, so zooming one tab would also zoom any other tab showing the same site — tabs in one workspace share a storage partition. Element box plus a CSS transform keeps every tab independent.

Placement

A sized page is centred in the column, both across and (when it fits) down, so it reads as a device preview rather than a page jammed into the top-left corner. Centring is done with translate(...) placed before scale(...) in the transform list: the offsets are therefore in unscaled column pixels while the scale still happens about the element's top-left corner.

Two deliberate limits:

  • Flexbox is not used for centring. Flex would centre the element's layout box, which is the full unscaled target (1280px inside a ~650px column), so it would push the scaled result off to one side while the translate did the same job again. One mechanism only; leftover flex centring is cleared on every apply so an already-styled container is corrected in place.
  • A page taller than the column is top-anchored, not centred. Vertically centring an 844px-tall phone page in a shorter column would clip both ends and hide where the document starts.

Consequences worth knowing:

  • At a desktop width in a narrow column, text is genuinely smaller — that is the honest cost of seeing the real desktop layout. Use a custom size (240–3840 × 320–2160) to trade layout for legibility.
  • A tab that is open but not mounted cannot be resized yet; the choice is stored and applied the moment you click that tab. A restored Session re-applies saved presets shortly after load.
  • Sizes are remembered per tab in localStorage under dsh.agent-browser.viewport.v1 and survive a restart.
  • The height is the preset's real height too. A "desktop 1280×800" tab lays out at exactly 1280×800 and is then drawn scaled-down inside the column. Scaling the height up to fill the column was tried and rejected: it stretched the viewport (that preset laid out 1571px tall) and silently broke 100vh and every height-driven layout. The scaled result is therefore often shorter than the column — which now shows as balanced space above and below the page.

Configuration

In the profile's cordis.patch.yml:

- id: dsh-agent-browser
  name: dsh-agent-browser
  config:
    # Allow browser_eval to run script in the page. The TOOL is always
    # registered; with this false it refuses and explains how to enable it.
    enableEval: false
    # Character budget for browser_read text/html.
    maxReadChars: 20000
    # browser_screenshot downscales to at most this width before delivery.
    screenshotMaxWidth: 1568

browser_eval is deliberately visible either way. Registering it only when enabled was the earlier design, on the reasoning that a deployment should not advertise a capability it has not accepted. Two independent agent reports showed what that costs: needing layout geometry (getBoundingClientRect, scrollHeight, getComputedStyle) — exactly what eval is for — neither agent could see the capability existed, and each rebuilt the same workaround instead, writing same-origin iframe probe pages into the project under test. That polluted a repository whose .gitignore did not cover them and travelled into the build output; one round shipped three bugs of its own, including a scrollbar artifact that nearly produced a false layout conclusion. An invisible capability is not a safe one, it is an unguessable one — so the tool is registered, refuses when the switch is off, and names the switch. Refusing grants nothing: the guard that closes the cordis_inspect_query route to the provider's eval method is unchanged, so the switch remains a real boundary rather than a hint.

The Inspect channel also has a process-wide client-query timeout, defaulting to 10 s. A slow page can exceed it, so raise it on the runner that owns the registry:

- id: cordis-host-runner
  name: '@deepseek-ai/dsh-cordis-host-runner'
  config:
    clientInspectTimeoutMs: 60000

Each browser call spends its own budget waiting on the page — 6 s for a read or an action, 15 s for a screenshot — deliberately under that default so a slow page answers "did not answer within 6s" instead of turning into an opaque transport timeout. Both cover the whole call, not one step of it: browser_act { action: "wait" }, navigation settles and the pause after a click all draw from the same deadline, so no single call can outlast the caller.

Be precise about what that budget buys, because a report read it as the total wait: it bounds how long the browser waits before giving up. It does not bound the call. When the Client reports a failure, the registry records it as a diagnostic and keeps waiting for a success, so the caller still waits out clientInspectTimeoutMs. That is why a failure can show both timed out after 60000ms and did not answer within 13s — the first is the channel, the second is this plugin, and only the second is a diagnosis. Raise clientInspectTimeoutMs (above) if you want a longer ceiling to spend; the plugin's budgets stay as they are, since they are not configurable today.

How it works

model → browser_* tool (Host)
          └─ ctx.cordisInspect.query('client', 'AgentBrowser', <method>, …)
                └─ WebSocket/RPC → the page → Client Inspect provider
                      └─ finds the tab's guest by carrier
                         [data-sidebar-right-session] > [data-sidebar-right-tab]
                           ├─ <webview>  executeJavaScript(...) / capturePage()
                           └─ <iframe>   refused: Web profile, unreadable
                ← lossless JSON
          └─ screenshot: ctx.attachments.saveImage() → { type: 'image', attachment }

Two design choices worth knowing:

  • The Inspect channel, not a private transport. cordisInspect is the one first-class Host→Client request/response mechanism: it correlates by requestId, honours the tool call's AbortSignal, and validates input and output against declared JSON schemas. Reusing it means no new wire protocol.
  • The page is driven through its own carrier. Each page-side helper is serialized with toString() and wrapped in a single IIFE before executeJavaScript, so the helpers see each other without touching the visited page's globals.

The provider reads [data-sidebar-right-tab] inside [data-sidebar-right-session] to map a tab to its guest. Desktop browser tabs declare keepMounted, so a tab keeps its DOM across tab switches, Session switches, collapse and floating — which is what lets a tool reach a tab the user is not currently looking at.

Both carriers are recognized. The built-in tags a Desktop guest <webview data-sidebar-browser-frame="webview"> and a Web guest <iframe data-sidebar-browser-frame="iframe">; the driver matches either and reports which one it found as carrier. Only the webview is drivable, so the iframe case is refused by name instead of being reported as a missing tab.

Opening a tab is addressed to the calling Session, never to the one on screen. sidebarRight.openTab resolves its Session from sidebarRight.mounted, so using it put a new tab in whichever Conversation was visible — not the one that asked. With two Conversations each driving their own project's dev server, the tab landed in the other Conversation's Sidebar, whose Agent then found it in its own inventory and drove a page belonging to the other project, while the calling Agent polled its own inventory and reported the misleading "has not mounted yet". The plugin therefore uses openTabIn(sessionId, …), and refuses rather than falling back to the on-screen entry when the on-screen Session is not the caller's. A tab that nevertheless appears in another Conversation is named in the error instead of being reported as merely unmounted. test/session-scope-check.cjs guards this.

Viewport sizing rides the same discovery path, but only for the webview carrier: a preset is written to the guest's own box, which an iframe does not expose to the host renderer. Sizing adds no DOM of its own: it sets inline styles on the guest and on the guest's existing parent container, because the built-in presentation re-appends the element on every re-present, which would move it straight back out of any wrapper this plugin inserted. The built-in component sets className and never style, so React leaves those inline styles alone. State for a tab whose guest has left the document is pruned as tabs are re-applied, so a closed tab cannot keep a ResizeObserver alive or report a stale display scale.

The controls sit in public additive seats — sidebar.right.tab.menu.item and settings.general.item — rather than replacing anything, so the built-in browser keeps working untouched and the whole feature disappears when the plugin is disabled.

Safety

The tools inherit every restriction the built-in browser already enforces, and add none of their own relaxations:

  • The guest keeps sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true.
  • The main process still refuses the DSH application origin, non-HTTP(S) schemes, and addresses carrying credentials, and still denies downloads, permissions, native popups and webview nesting.
  • executeJavaScript runs in the page's own world; the host renderer never exposes Node or Electron to visited content.
  • browser_eval is off by default. Turning it on lets the model run arbitrary script in the page — treat it like granting shell access to that origin. The switch is enforced on the Host, not just in the tool list. The Client provider always declares an eval method, and the model also holds cordis_inspect_query, which can reach that method directly; a Client half cannot read plugin config (boot-manifest rows carry none), so the Host half installs a tool guard that denies cordis_inspect_query calls targeting client → AgentBrowser → eval whenever enableEval is not true.
  • browser_eval runs the code in the page's own world. An expression is tried first, and only a genuine SyntaxError falls back to running the source as a statement body — a runtime error is never retried, because a statement body that already had a side effect must not run twice.

Page content is untrusted data. Anything a page shows can attempt prompt injection. Treat tool output as data to reason about, never as instructions.

Known limitations

  • Desktop only, as above.
  • Mounted tabs only. A tab that is open but not yet mounted cannot be driven; the tool says so and asks you to click that tab once. (In practice tabs mount as soon as they are opened or restored.) Setting a viewport is the exception: it is stored and applied when the tab mounts.
  • Opening a tab needs this Conversation's Sidebar to have a surface. The Session-addressed open is a silent no-op for a Session whose Sidebar store has never been adopted, so browser_navigate without a tabId can fail with "not created" in a Conversation that has never shown its right Sidebar. Open that Conversation's Sidebar once and retry. Navigating an existing tab is unaffected.
  • shortcuts.register requires a defaults map. Omitting it throws during apply(), which takes down the entire client half — the Inspect provider included — and surfaces only as web boot: … did not activate. This plugin ships defaults: {}; test/apply-check.cjs guards the shape so the outage cannot come back silently.
  • The menu item reads sessionId from a standard slot prop. It falls back to the enclosing [data-sidebar-right-session] only if a future shell stops providing it, so the control keeps working either way.
  • The tab menu is a right-click menu, not a "⋯" button. The kit's tab strip has no overflow button; the menu is opened by the tab's onContextMenu handler. Modal is deliberately not used for the custom-size form: the kit dismisses the menu on any pointerdown outside its own element, and Modal portals to document.body, so a portaled dialog would destroy itself on first click. The form renders inline and stops pointer events instead.
  • jsx(type, props, key) takes the React KEY third — not children. This plugin shipped a bug from exactly that: labels were passed as the third argument, which compiles and renders empty buttons (the live menu showed six blank rows). Children always go in props.children. test/apply-check.cjs asserts every menu item carries a non-empty label.
  • A store handle can only mount under one slot scope. Declaring store on a registration makes the renderer bind useStore/actions, but the same handle cannot be mounted under two different scopes — this plugin needs its state in both a session-scoped slot (the tab menu) and a root-scoped one (Settings → General), and doing so throws store handle mounted under … is already mounted under scope …. So the store is passed as a plain prop and the components subscribe with React.useSyncExternalStore directly. The snapshot must be identity-stable; test/apply-check.cjs asserts the components declare no store handle.
  • A screenshot that cannot be delivered still answers. If no attachment store is mounted, or the store refuses the image (an oversized capture, for instance), the result drops the bytes and explains why in note rather than failing the call.
  • Every guest call is bounded by the plugin's own budget. 6 s for reads and actions, 15 s for a screenshot, and 1 s for the identity probe every method makes. Electron's executeJavaScript, capturePage and loadURL accept no abort signal and no timeout, so an unresponsive renderer never settles; racing a timer is the only bound the plugin controls. Awaiting them bare is what made a stalled page cost a fixed 60 s per call (the Inspect channel's default timeout) — eight of those cost one agent about eight minutes. The budget is not configurable: the Client half cannot read plugin configuration, so it is a constant rather than a setting, and clientInspectTimeoutMs still governs the outer channel.
  • browser_tabs shares one deadline across the whole listing. Each tab needs its own identity probe, so a Session full of stalled tabs could otherwise cost one full budget per tab — an unbounded answer to the question that is supposed to be the cheap way to see what is open. Each tab gets a fair share of what remains, floored so a small listing is not starved.
  • A timed-out operation is not cancelled. Because Electron cannot abort one, the work may still complete in the background after the call reports the timeout. The message is a statement about how long the plugin waited, never a claim that the work stopped.
  • A screenshot defaults to the Sidebar column width. auto is typically 400–700 CSS px, which is not a real device size. Pass a preset (or set one with browser_viewport first) when the layout matters.
  • Screenshots are viewport-only — there is no full-page capture.
  • ref values expire when the page re-renders. A stale ref is refused with a message telling you to read the elements again; it is never guessed at.
  • Multiple windows. A client Inspect query is broadcast and the first valid page answer wins, so with several windows open an answer may come from a different window. Single-window use is unaffected.
  • Text extraction is innerText — it reflects what is rendered, so off-screen or virtualized list content may be absent. Use mode: 'html' or browser_eval when you need more.
  • active needs an on-screen Session. It comes from the app's own sidebarRight.commandTarget(), which reports the focused pane's tab or the on-screen Session's active tab. For a Session that is not currently displayed it cannot answer, and every tab then reports active as whatever is mounted — the same fallback used if a future build drops the method.
  • Viewport sizing is Desktop-only by construction. A preset is applied to the guest's own box, which only the <webview> carrier has; a Web-profile <iframe> tab reports its stored preset but is never resized.
  • The Inspect channel is documented as read-only and this plugin uses it to carry write operations (click, type, navigate). Nothing enforces that today, but if a future release adds such a constraint, this plugin would need its own transport instead.
  • Plugin code changes need a restart. The profile config is watched, so a config: change re-applies live, but the loader keeps the module it already imported; editing index.js or client.js takes effect on the next launch (a page refresh picks up client.js).
  • Automatic bundle selection is the installer's behaviour, not a guarantee. A newly installed package that declares dsh.bundle is appended to dsh.profile.bundles, which is why one command is enough. If a future release stops doing that, add "dsh-agent-browser" to dsh.profile.bundles by hand and restart.
  • The CLI cannot install into a profile while the app runs. Use the Plugins page or the agent tool instead, or quit the application first.

Install

This is a third-party bundle plugin: it is loaded from your DSH profile, not from app.asar. Nothing in the installed application is modified, so an uninstall is exact and an app update cannot clobber it.

DSH ships a plugin manager, and this repository is a valid bundle, so one command is enough. No cloning, no editing files by hand, no manual links.

Option A — one command (CLI)

dsh plugin --profile desktop add github:guanhaisen/dsh-agent-browser

Fully quit DeepSeek Harness first. The CLI refuses to touch a profile the running application owns; the other two options work while it runs.

Option B — the Plugins page (no terminal)

  1. Open the sidebar's Plugins entry.
  2. Choose install, and paste the same spec: github:guanhaisen/dsh-agent-browser.
  3. Watch pnpm's output, then confirm.

Installed bundles appear on that page, where you can also switch them off, remove them, and open any configuration page they register.

Option C — ask the agent

Tell the model to install github:guanhaisen/dsh-agent-browser in the desktop profile; it uses the plugin_manager tool. Every tool action needs danger-full-access or an approval.

Pin a version (recommended for a stable setup)

The specs above track master. To stay on a released version:

dsh plugin --profile desktop add 'github:guanhaisen/dsh-agent-browser#v0.1.0'

What the installer does for you

You do not have to write any of this by hand:

  • adds the dependency to <dsh-home>/profiles/desktop/package.json,
  • appends the bundle to dsh.profile.bundles automatically — a freshly installed package that declares dsh.bundle is reconciled into the list, which is what actually mounts it,
  • runs the bundled pnpm (no separate pnpm install needed),
  • and, because the Desktop composition includes HMR, applies the change live — no restart.

If the tools do not appear, the bundle was not selected: add "dsh-agent-browser" to dsh.profile.bundles in <dsh-home>/profiles/desktop/package.json and restart.

Turning tools on or off

Optional — the defaults are safe. In <dsh-home>/profiles/desktop/cordis.patch.yml:

- id: dsh-agent-browser
  name: dsh-agent-browser
  config:
    # Allow script execution in the page. The tool is always listed; with this
    # false it refuses and tells the model how to enable it. Turn it on if you
    # do layout verification — measuring geometry any other way means injecting
    # probe pages into the project under test.
    enableEval: false
    maxReadChars: 20000
    screenshotMaxWidth: 1568

Raise the Inspect timeout if you take large screenshots, since a slow page can exceed the 10 s default:

- id: cordis-host-runner
  name: '@deepseek-ai/dsh-cordis-host-runner'
  config:
    clientInspectTimeoutMs: 60000

A config: change re-applies live; editing the plugin's own index.js needs a restart.

Where the controls appear

  • The model gains eight tools (browser_eval is always listed; it refuses until enableEval: true).
  • Right-click a browser tab for Viewport size (fit / phone / tablet / desktop / custom).
  • Settings → General → Browser viewport sets the default for new tabs.
  • browser.viewport.cycle is registered but ships unbound; bind it in Settings → Shortcuts if you want a keyboard shortcut.

Verify it worked by asking the model to call browser_tabs — it should list the tabs open in the right Sidebar.

Uninstall

Use the Plugins page's remove action, or:

dsh plugin --profile desktop remove dsh-agent-browser

Either drops the dependency and the bundles entry. To keep it installed but inactive, switch the bundle off (or delete only the bundles entry) and restart. Nothing in app.asar is touched, so the built-in browser returns to exactly its shipped behavior.

Manual install (fallback)

Use this only when the above is unavailable — a network that cannot reach GitHub, a machine without pnpm, or hacking on the plugin itself, where a local link means your edits take effect without republishing.

  1. Clone it somewhere permanent (not inside the app install):

    git clone https://github.com/guanhaisen/dsh-agent-browser.git
    
  2. Add it to your Desktop profile. Edit <dsh-home>/profiles/desktop/package.json — <dsh-home> is usually C:\Users\<you>\.dsh on Windows or ~/.dsh elsewhere:

    {
      "dependencies": {
        "dsh-agent-browser": "link:/absolute/path/to/dsh-agent-browser"
      },
      "dsh": {
        "profile": {
          "bundles": [
            "...your existing bundles...",
            "dsh-agent-browser"
          ]
        }
      }
    }
    

    Both parts are required: the link: dependency makes the package resolvable, and the bundles entry is what actually mounts it.

  3. Make the package resolvable to Node. Either run your profile's package manager, or create the link by hand:

    # Windows, from an elevated shell is NOT required
    New-Item -ItemType Junction `
      -Path  "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-agent-browser" `
      -Target "<absolute path to the clone>"
    
    # macOS / Linux
    ln -s /absolute/path/to/dsh-agent-browser \
          ~/.dsh/profiles/desktop/node_modules/dsh-agent-browser
    
  4. Restart the application.

Do not combine the manual link with an installer run for the same package: the manager would try to replace the link and pnpm can fail with EPERM: rename on Windows. Pick one route.

Requirements

  • Desktop app only. The tools drive an Electron <webview> guest. Web profiles render a sandboxed cross-origin <iframe> whose DOM cannot be read, so the tools report an explicit reason instead of pretending to work.
  • DSH 0.2.0-rc.2 was the version this was built and verified against. The plugin relies on public contracts (the Inspect provider registry, the sidebar.right.tab.menu.item and settings.general.item seats, and the [data-sidebar-right-*] DOM attributes), so it should survive patch updates — but see Known limitations for what happens if a contract moves.
  • Node 20+ to run the test suite.

Tests

Eleven suites, no test framework — plain Node scripts that exit non-zero on failure. The executable is located automatically (a sibling install, or DeepSeek Harness.exe somewhere up the tree); normally you do not pass it.

node test/run-all.cjs

If your install is somewhere unusual, point at it explicitly, either way:

node test/run-all.cjs "/path/to/DeepSeek Harness.exe"
# or
DSH_DESKTOP_EXE="/path/to/DeepSeek Harness.exe" node test/run-all.cjs

Three suites import the real validators out of the application's app.asar, so they cannot run without a packaged install. On a machine that has none — CI, for instance — pass --offline to skip just those three; they are reported as SKIP, never as a pass:

node test/run-all.cjs --offline
Suite What it pins down
schema-check Every tool's raw JSON Schema against the real dsh-tools validator, and that each tool's required fields match whether it accepts an empty call.
client-check The Client provider manifest, pageCall codegen, guest lookup in a stub DOM, getViewport's implicit-tab resolution, and the Web-profile refusal.
page-helpers-check Read / click / type / select / refusal paths, run against a DOM stub, plus the wait condition: that a met condition reports matched, that an unmet one FAILS rather than returning success, that attached and visible stay distinct questions, that hidden accepts absence, that an invalid selector is refused as invalid rather than as a timeout, and that a wait with no condition still just sleeps.
eval-gate-check The Host-side enableEval guard, including the cordis_inspect_query bypass.
viewport-check Sizing math, validation and clamping, and setViewport dispatch wiring.
apply-check apply() activation and seat registration — if this fails, the whole client half is down.
viewport-controller-check Which element gets which inline style, centring, fallbacks, unmounted tabs.
contract-check Every tool's real result shape — success and degradation — through the registry's own lossless-JSON detach and output-schema validation, plus the truncated flag's semantics and active-tab reporting.
session-scope-check That opening a tab is addressed to the calling Session, not the on-screen one, and that a tab landing in another Conversation is reported as such rather than as merely unmounted.
resilience-check That a stalled page is bounded by the plugin budget instead of stalling the call; that a viewport change is confirmed rather than assumed; that a failed load is reported as failed and refused by the screenshot path; that a wedged, unscriptable page is not captured as a blank image; that a failed capture diagnoses visibility (hidden Conversation, collapsed Sidebar, another tab drawn, or visibility ruled out) and never blames reachability or prescribes reload; that a tab listing shares one deadline; and that reload/close actually work. A watchdog turns a hang into a failure — without it, removing the bound under test would make the process exit 0 and look green.
host-error-check How the Host explains a failed Client query, in pure Node so it runs everywhere: a provider that ANSWERED keeps its own diagnosis and is not called unreachable or blamed on the Session kind; a genuinely absent provider/page/method keeps the reachability explanation; a silent timeout names the visibility possibility without asserting it; and a cancelled call or a schema rejection passes through untouched.

Three of these must run under the packaged executable in Node mode, because they import the real validators out of app.asar; run-all.cjs does that for you.

Several assertions exist because a bug shipped once and was only visible at runtime: a missing shortcuts.register defaults map that silently took the entire client half down, jsx(type, props, key) labels passed as the React key rendering blank buttons, a menu that dismissed itself because Modal portals outside the kit's click-through boundary, a viewport height that stretched the page and broke 100vh, a new tab opened through the on-screen entry, which put it in another Conversation and let that Conversation's Agent drive the wrong project's page, and a set of reporting bugs that each made a failure look like success: an unbounded guest call that stalled for a fixed 60 s, a viewport change reported before the page had reflowed, and a failed load that read as healthy because Electron's URL overrode the page's.

Two of those are worth naming because the wording itself was the defect. A failed capture was explained as "the browser is not reachable from this Session — use a Desktop Session", which was false on both counts in the report that produced it (the user was on a Desktop Session, and browser_read worked against the same tab throughout), and it buried the provider's own diagnosis. And it prescribed reloading, which a later report measured as having no effect — a hidden tab is not composited, and reloading cannot change that.

test/mutation-probe.cjs guards the guards. It reintroduces each original defect in turn and fails if the check that should catch it does not go red, so a check that has quietly stopped covering its defect cannot pass unnoticed. It mutates both halves — the Client half owns capture and visibility, the Host half owns how a failure is explained:

node test/mutation-probe.cjs

contract-check exists for the same reason. A green schema-check proved nothing about whether a result could actually be returned: browser_viewport's read omitted the tabId its own output schema required, and both browser_screenshot fallbacks emitted base64: undefined, which the registry's lossless-JSON detach rejects outright — so a documented read call and every graceful screenshot degradation threw at runtime. That suite now drives each tool's real result through both registry stages, and drives the Host's screenshot branches directly so their shapes cannot drift.

Model experience

Eight tools enter the model-facing tool list, always — browser_eval included, refusing when enableEval is off rather than being absent (see Configuration). The tool schemas are stable; results are lossless JSON. Screenshots add one image block per call, so they consume image tokens — prefer browser_read for text and reach for browser_screenshot when layout or visual state matters.

A tool result never carries a null or missing value where the model needs a fact to reason with: a screenshot that cannot be delivered says so in note instead of returning unusable bytes, browser_read's truncated distinguishes "the list hit its cap" from "hidden elements were filtered", browser_tabs reports each tab's carrier so a Web-profile tab is visibly undrivable rather than apparently absent, and failed/unverified say whether a tab has no page and whether its identity was observed or inferred.

Screenshots name their own viewport. The result includes preset, pageWidth, pageHeight and scale, so the model can tell which size it actually captured at — and the tool description tells it to pass preset: "desktop" when browsing and preset: "phone" when the work is mobile, rather than leaving the viewport at the auto column width.

License

MIT.

—/ 5

暂无评分

已验证 DSH bundle

Commit 063465a77aa7

社区评论

还没有评论,来写第一条。

DSH HUB

社区维护的 DSH 插件索引。不是 GitHub 或 DeepSeek AI 的官方产品。

社区资源API关于