dsh-plugin
DayuanJiang/next-ai-draw-iodsh 的 draw.io 绘图插件:通过 next-ai-drawio MCP 服务器作图,点回复里的链接在右侧栏打开实时预览,可以手动修改图,AI 的每次修改都是可以恢复的版本。需 Node.js 18+,首次使用由 npx 下载 MCP 服务器。
让 agent 读写浏览器所在电脑上的文件:浏览器授权本地目录(File System Access),工具调用经插件自建 WebSocket 中继;移动端/非安全上下文自动降级只读兼容模式。
中文 | English
Lets dsh's agent read and write local files on the machine where the browser runs. dsh's built-in fs tools can only reach the host machine; when dsh is deployed remotely, the browser is on another machine and the agent cannot touch your local files. This plugin fills the gap:
The user authorizes a local directory in the dsh web page via the File System Access API
(showDirectoryPicker), and the handle is stored in IndexedDB; the agent works with three
model tools — list/read/write — whose calls are relayed to the browser over the plugin's own
WebSocket channel.
A two-sided plugin (cordis plugin system):
src/index.ts, runs in dsh's host Node process)
ctx.webServer.registerUpgrade registers the WS channel at the exact path /browser-fs/ws;ctx.tools.register registers browser_fs_list / browser_fs_read / browser_fs_write;{type:'call', rpcId, op, args} frame to the tab "holding the authorized
handle" and pairs result frames by rpcId; exec.signal is wired to pending-call abort
(a cancel frame is also sent to the browser).src/client/, runs in the browser)
queryPermission; connects
back to the host's WS (exponential-backoff reconnect);shell.overlay layer: shows connection/authorization
status and provides 授权目录 (authorize) / re-authorize / switch / revoke buttons.{type:'state', hasHandle, dirName} on authorization changes; the host only
dispatches calls to tabs with hasHandle=true (executor selection when multiple tabs
are online).# Install from npm (recommended; no install scripts, no build authorization needed)
dsh plugin --profile web add dsh-browser-fs
# Or install from GitHub (build artifacts are committed, zero install scripts)
dsh plugin --profile web add github:whitefirer/dsh-browser-fs
# Local development: reinstall after changes (run npm run build first; lib/ is committed)
npm install
npm run build
dsh plugin --profile web add file:/abs/path/to/dsh-browser-fs
# Restart dsh to take effect
dsh plugin add installs the package into the profile's dependencies and, thanks to the
dsh.bundle.patch manifest declaration, automatically appends dsh-browser-fs to the
dsh.profile.bundles layer stack (the patch is this repo's cordis.patch.yml: one insert
line mounts the host half; its config carries wsPath and requestTimeoutMs).
browser_fs_list { path?, recursive? } — list a directory (relative path/kind/size, optional recursion)browser_fs_read { path, maxBytes? } — read a text file (256 KiB cap by default, truncation marked)browser_fs_write { path, content } — write a text file (parent directories auto-created, returns byte count)The tool descriptions tell the model explicitly: these operate on the browser machine's local disk, not the host's filesystem.
The card UI language follows dsh's own Settings → General → Language switch (the plugin
subscribes to the dsh client's locale service, live; compositions without that service
fall back to <html lang>/browser language).
Click a file name in the contents tree to pop the preview window (mask + default-size window, min(720px,92vw) × min(70vh,560px); drag the title bar to move it, drag the bottom-right corner to resize; pinned title bar — file name + size/truncation note + ✕, with the relative path below; content area scrolls independently; ✕ / mask click / ESC to close):
<img> (revokeObjectURL on close); images over 8MB are not fetched — a too-big
notice is shown instead;<pre>;
truncation is marked ("仅前 64KB"); decoded text containing NUL counts as binary and shows
"二进制文件不支持预览" (binary files not supported).Preview takes the same path in both modes (the full/compat backends each implement
readBlob), so it works in read-only compat mode too.
Text/code preview supports editing (full mode only, i.e. when the backend is writable):
click Edit to load the complete file into a textarea; saving goes through the same
backend write path as the agent tool browser_fs_write (FsBackend.write), then the
preview refreshes automatically. Large files are still shown as the first 64KB only, and the
editor clearly notes that the full file has been loaded and saving overwrites the whole
file. Compat mode stays read-only and does not show the edit entry. While editing, ESC first
exits editing; a second ESC closes the window. The editor shows line numbers and uses the
same syntax highlighting described below.
Text preview comes with syntax highlighting: the extension maps to a language
(js/ts/tsx/py/go/rs/java/c/cpp/h/sh/yaml/json/toml/md/html/css/xml/sql, etc.; unmapped
extensions stay plain text), and only the truncated first 64KB is highlighted. Highlighting
uses a highlight.js language subset with the GitHub Dark theme; to keep the main bundle
small it ships as a separate chunk — the host half serves it at highlight.mjs next to
/browser-fs/ws, and it is dynamically imported the first time a preview hits a mapped
language (plain text with a "语法着色加载中…" note while loading, silent fallback to plain
text on failure).
The card is draggable: the title row is the drag handle (mouse and touch; movement
beyond 4px counts as a drag, so collapse/button clicks are never eaten); a bottom handle
lets you drag vertically to resize the card height (minimum 160px, persisted under
localStorage key dsh-browser-fs:card-height); while dragging, the panel/ball tracks the
pointer directly, with clamping applied only on release and window resize. The ball always
stays fully inside the viewport (flush to edges, no hidden margins). An expanded panel that has never been dragged derives its initial position from
the ball (flipping leftward/upward when the ball sits in the right/bottom half, then
clamping into a 10px margin with width/height capped to the viewport); once dragged it
stays where it was dropped (viewport clamp only, never flipped), and the position is
remembered across collapse/expand and page reloads (same localStorage key
dsh-browser-fs:card-pos as the ball). The collapsed 📁 ball sits at the anchor (the
card's top-left); it is draggable too — a release without movement expands the card.
Card, ball and preview window render at body level (z-100/200): above common overlays
(e.g. sidebar-plugin panels) so clicks are never stolen by other plugins, yet still below
dsh's own modals (z-1000+).
The "↻" at the end of the authorization button row refreshes the directory:
Multiple devices can each keep a dsh page open; the model sees "each device authorizes its own local directory":
dsh-browser-fs:device-name; the nickname wins over UA derivation).{hasHandle, dirName, label} to the host; the host maintains an
executor roster and broadcasts it to all online tabs. Devices without authorization see
"当前授权在设备:某某(目录名)" on the card — no more guessing where the grant lives.The File System Access API is gated behind secure contexts: it only exists under HTTPS or
localhost. On LAN http (e.g. http://192.168.0.x:9101, common when a phone goes through a
proxy), window.showDirectoryPicker simply does not exist and cannot be polyfilled. This
plugin feature-detects with typeof window.showDirectoryPicker === 'function' (not
isSecureContext — a proxy-injected polyfill may have patched it), and falls back to
compat mode when missing. Capability differences:
| Full mode | Compat mode | |
|---|---|---|
| Trigger | Secure context (HTTPS/localhost) | Automatic fallback on insecure contexts |
| Picking | System directory picker (showDirectoryPicker) | Dual entries: input[webkitdirectory] for directories / multiple for file multi-select |
| list / read | ✓ | ✓ (in-memory File map; read slices instead of loading whole files) |
| write / mutations | ✓ | ✗ explicit "兼容模式只读…" (compat mode is read-only) error |
| Grant persistence | ✓ IndexedDB, restored on reload | ✗ nothing to persist; re-pick after reload (noted in the status line) |
| Directory-tree browsing | ✓ | ✓ same path (the backend abstraction is shared) |
Mobile behavior: the compat authorization area offers two side-by-side entries — 选择目录 (pick directory) and 选多个文件 (pick files) — instead of relying on capability probing alone; the picker input is positioned off-screen (not display:none, which mobile browsers and the WeChat WebView block from programmatic clicks). On iOS, directory picking may be a dead end (the webkitdirectory attribute exists but picking a directory returns 0 files): the card then shows a clear error ("没读到文件…请改用「选多个文件」") and the directory entry silently switches to multi-select from then on — never silent. After a successful pick, the status line shows what was selected (directory name or "N 个文件").
In compat mode the card shows a 兼容模式 (compat mode) badge with an explanation. Three ways to get full mode:
ssh -L 9101:127.0.0.1:9101 user@host, then visit
http://127.0.0.1:9101;chrome://flags/#unsafely-treat-insecure-origin-as-secure and allowlist
the LAN origin;/browser-fs/ws (the highlight
chunk path derives from the same directory: /browser-fs/highlight.mjs); if the host
half's wsPath config is changed, the client's DEFAULT_WS_PATH (src/wire.ts) must be
updated in sync and the bundle rebuilt.@deepseek-ai/dsh-tools (defineTool); at runtime it
resolves through the profile's flat node_modules fallback to the same copy bundled with
the dsh installation.npm run build # esbuild: host half ESM + client half CJS closure (__ModuleLoader__ wrapped) + highlight lazy chunk (lib/highlight.mjs)
npm run typecheck # tsc --noEmit
npm run smoke # link self-check: call→result round-trip / abort / disconnect / cross-origin rejection (scripts/smoke.mjs)
After changing code: re-run npm run build, then dsh plugin --profile web add file:<this dir> once more (file: dependencies are packed copies, not symlinks), and
restart dsh.