# dsh-remote-vs · VSCode-style remote development for DSH [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [中文](./README.md) | English SSH host management inside the DSH Host process + helper push pipeline + a **VS Code-style remote workbench** (explorer / search / source control / commit graph / multi-tab editor / side-by-side diff / multiple terminals). Hosts live in DSH Settings ("Remote hosts"); with `dsh-better-sidebar` installed there is an extra standalone "Remote" tab, otherwise it falls back to a native right-Sidebar "Remote" page (same content, self-opened from the guide, per-session instances). The editor is ALSO mounted into the conversation view slot as a "远程编辑器" (remote editor) tab next to "对话 / 轨迹", so the code area is full width; the sidebar copy is the narrow fallback. Since P2, native tools inside remote workspaces transparently run remotely (see below). ```text ┌─ Local DSH Host ─────────────────────────┐ │ Plugin host half │ Client half │ │ hosts/keys/push/ │ settings + native │ │ routing │ entry takeover │ │ /api/dsh-remote/* │ explorer page │ └─────────────────────┴────────────────────┘ │ ssh2 (files/proc/terminal) + helper RPC (remote fs) ▼ ┌─ Remote Linux ─────────────────────────────┐ │ ~/.dsh-helpers///dsh-ssh-helper│ │ user-chosen remote working directory │ └────────────────────────────────────────────┘ ``` ## Install ```sh dsh plugin add github:xiray-xia/dsh-remote-vs ``` Requires DSH `0.2.1-alpha.4`. On first install, allow the printed build scripts in the profile's `pnpm-workspace.yaml` and re-run; then restart `dsh web` and open Settings → Remote hosts to configure. ## Capabilities ### Remote workbench (VS Code-style UI) | Capability | Notes | | --- | --- | | Explorer | Lazy tree + expand/collapse + hidden-files toggle; context menu: new / rename / delete / copy / move / paste / find in folder / insert `@` reference; git status colours and folder roll-up marks | | Search | Two modes: **file names** (remote `find`) and **contents** (remote `grep`). Content mode has a scope folder, `Aa` (case) / `ab_` (whole word) / `.*` (regex), results grouped per file with match highlights, and clicking a hit jumps to that line | | Source control | **Repository-folder picker** (for workspaces whose git repository lives in a sub-folder: browse the remote tree, one-click jump to any repository discovered inside the workspace, type a relative or absolute path, and see which repository each folder resolves to); changes / staged / untracked / ignored buckets; per-file AND **per-hunk** stage / unstage / discard (behind a confirm) / commit; branches, remotes and stash panes; sync (fetch/pull/push) | | Side-by-side diff | Panes stay **exactly equal** + per-hunk buttons in the middle gutter + a **top horizontal scrollbar** (a very long line can no longer break the symmetry) + pinned line numbers, **rendering only the rows inside the viewport** (a 3000-line file goes from 9000 row containers to ~174, with exactly the same scroll range as a full render); switch between "before / after", and the after side is editable and savable | | Commit graph | SVG branch/merge graph + commit details + click a commit to expand its changed files + open the diff | | Multi-tab editor | Open/save/reload, dirty marks, several files at once; image preview, Markdown/HTML preview (source vs preview) | | Syntax highlighting | VS Code **Dark+ / Light+** palettes; covers java/aidl/smali, c/c++, go, rust, js/ts, json, py, yaml, xml, html/css, md, sh, properties/rc/toml, diff …; a **fallback colorer** takes over when no grammar matches, so a file is never left monochrome. Honest caveat: kotlin / gradle / smali are coloured by the Java grammar (no dedicated package), which gets keywords, strings and comments right but does not split their own syntax | | Find | In-editor `Ctrl+F` (`Aa` / `ab_` / `.*`, `3/17` count, ↑↓, `F3`/`Shift+F3`, `Esc`); **selected text is carried into the box by `Ctrl+F`**; the widget is pinned to the top-right and does not scroll away | | Jump | Clicking a search hit opens the file at that line, centres it and **lights the landing line** (fades after ~2.6s); repeat jumps in the same file work | | Terminals | Tabbed multi-terminal (`+` to create, `✕`/middle-click to close behind a confirm), across hosts; session ids are persisted so a page refresh reattaches to the running shells, and a session is kept for **10 minutes after its last socket goes away** (enough for a refresh or a browser-tab switch) before the remote shell is closed | | Workspaces | Binding list + routing-mode dropdown + the dual-mode (local / remote) new-workspace wizard | ### Hosts and transport | Capability | Notes | | --- | --- | | Host management | CRUD + connectivity test; key / password auth; config in `$DSH_HOME/dsh-remote.json` (0600), independent from `dsh-ssh.json` | | Key bootstrap | One click generates a dedicated keypair on password hosts and deploys `authorized_keys`, key auth from then on | | Push pipeline | Remote arch/OS probe → version compare → skip-if-same / else push; the executable channel pushes a self-built helper. A failed deploy no longer retries on every remote file operation: it backs off from **5 seconds up to 5 minutes**, and the Settings "Push" button forces an immediate retry | | Remote files | SFTP browse / read / write / create / delete / rename. Boundaries hardened: 0-byte files no longer deadlock, and every SFTP operation times out at 60s and releases its channel; **a single directory listing is capped at 20000 entries** (the first 20000 after sorting — directories first, then names — announced in the host log; the protocol carries no "truncated" field, so the UI has no marker) | | Remote terminal | ssh2 PTY + WebSocket, xterm rendering (copy-paste/theme-follow/reconnect-on-refresh); **a reattach only takes over, it does not resize** (a page reload no longer shrinks a running shell to 80×24); an output backlog applies real backpressure to the far end; **frames are capped at 1 MiB**, and pastes past 64 Ki characters are chunked on code points by the client instead of dropping the connection | | Concurrent channels | At most **6** remote channels per host at a time (no longer colliding with the far end's sshd `MaxSessions`); the 7th queues for up to **30 seconds** and then fails with `remote host is busy` instead of piling on connections | | Timeouts and process cleanup | exec runs in its own session (`setsid --wait`); on timeout it sends TERM and **escalates to KILL after 3 seconds**, reaping the child processes with it; a host without `setsid --wait` is detected by a one-shot probe and falls back to the old behaviour, with a host-log line saying the timed-out remote command may still be running | | Git graph | Remote repo status + branches + SVG commit graph + commit details (exec + frontend parsing, zero Host additions) | | Import | One-click import from `~/.ssh/config` and from an existing `dsh-ssh.json` (read once, no coupling) | ### Agent capabilities | Capability | Notes | | --- | --- | | Agent tools | `remote_hosts` / `remote_exec` / `remote_read` / `remote_write` / `remote_ls` | | **P2 transparent remote execution** | Native `read/write/edit/bash/glob/grep/jobs` inside remote workspaces run directly on the far end | | **P3 remote @ references** | `@` in remote sessions completes remote files (fileReferences router: scoped live listings / bare queries via a remote rg index) | ## Project structure ```text dsh_remote_vs/ ├── cordis.patch.yml # composition: plugin row + private local realm + routers + preset-remote ├── src/ │ ├── index.ts # plugin entry: store/pool/worlds/routes/tools/preset-switch wiring │ ├── protocol.ts # frontend/backend wire contract (route table + pure types, shared) │ ├── paths.ts # four-piece addressing under $DSH_HOME (store/keys/build cache/alias dirs) │ ├── store.ts # host + binding + tombstone persistence (0600 JSON, atomic writes) │ ├── pool.ts # ssh2 long-lived pool (exec/shell/sftp/putFile, TOFU host keys) │ ├── sftp.ts # remote file engine (SFTP windowed reads-writes/tree ops) │ ├── keygen.ts # password-host key bootstrap (generate→deploy→flip) │ ├── deploy.ts # push pipeline (probe/compare/ship/double-verify/manifest) │ ├── importers.ts # ssh_config / dsh-ssh.json parsing importers │ ├── routes.ts # /api/dsh-remote/* route table + terminal WebSocket upgrade │ ├── terminal.ts # PTY session registry (reconnect-on-refresh/disconnect cleanup) │ ├── tools.ts # the 5 Agent tool definitions │ ├── http.ts / loopback.ts # JSON body IO + loopback trust fence │ ├── git-format.ts # git command builders + pure frontend parsing (zero Host additions) │ ├── git-ops.ts # the full remote git surface: status/diff/branch/remote/stash/commit/hunk patch builders + parsers │ ├── fs-ops.ts # remote file-operation command builders (copy/move/remove/stat) │ ├── search-ops.ts # remote content search (grep builder + NUL-separated parser + match splitting) │ ├── p2/ # ── P2 transparent routing ── │ │ ├── worlds.ts # world decisions (cwd key/alias/tombstones) + remote RPC/FS/proc contexts │ │ ├── helper-rpc.ts # helper wire protocol over an ssh2 exec channel (handshake/heartbeat) │ │ ├── router-fs.ts # transparent fs router (target→world map/path translation/policy rewrite) │ │ ├── router-subprocess.ts # transparent process router (argv0 reduction/pwsh gate) │ │ ├── ssh-proc.ts # ssh2 process runtime (setsid group kill/marker/stdin/spill) │ │ ├── shell-bash.ts # routed bash executor (local verbatim/remote strips DSH_*) │ │ ├── router-spill.ts # transparent spill router (session world key/remote dir) │ │ ├── router-refs.ts # transparent @ reference router (agent world key, P3) │ │ ├── file-refs.ts # remote @ search (live fs-ssh listings/rg index/ported stock scoring) │ │ ├── bridge.ts # private-realm local publishes (localFs and other early services) │ │ └── bridge-refs.ts # standalone refs bridge (local-refs needs workingDirectory: must split) │ └── client/ # ── browser half (React, bundled into lib/client.js) ── │ ├── index.tsx # surface decision (better-first/settle-boost/native fallback) + settings + directoryFlow takeover │ ├── store.tsx # module-level state shared by both surfaces (useSyncExternalStore; open files/terminals/view live here) │ ├── RemoteRoot.tsx # sidebar surface: VS Code activity rail (explorer/search/SCM/history/editor/terminal/workspaces) │ ├── RemoteEditorView.tsx # conversation-view surface (the "remote editor" tab next to 对话/轨迹, full width) │ ├── RemoteExplorer.tsx # explorer: tree + context menu + file ops + @ references + status marks │ ├── SearchView.tsx # search panel: name / content modes + Aa/ab_/.* + grouped hits + jump │ ├── EditorPane.tsx # editor pane: tabs + info bar (build stamp / tok count) + image/MD/HTML preview │ ├── CodeEditor.tsx # CodeMirror wrapper: grammar table/theme/find widget/jump/fallback wiring │ ├── scm/ # ── source control ── │ │ ├── ScmPanel.tsx # changes/staged/untracked + per-file and per-hunk actions + commit box │ │ ├── DiffView.tsx # side-by-side diff (top scrollbar/hunk buttons/editable after side) │ │ ├── HistoryPanel.tsx # SVG commit graph + commit details + expanded changed files │ │ ├── actions.ts # remote git action layer (over the exec channel) │ │ ├── diff-model.ts # unified diff → side-by-side row projection (pure) │ │ └── status-map.ts # status bucketing + tree marks (pure) │ ├── vsc/ # ── VS Code skin and controls (zero new runtime deps) ── │ │ ├── kit.tsx / vsc.css # icons/dialogs/context menu/segmented control + all styles (scoped to .rvs-root) │ │ ├── editor-palette.ts # Dark+/Light+ palette + fixed class names (rvs-ck-*) │ │ ├── editor-theme.ts # editor chrome theme (live light/dark swap) │ │ ├── editor-langs.ts # file extension → CodeMirror grammar │ │ ├── fallback-highlight.ts# coloring used when no grammar matched (via tokenize.ts) │ │ ├── FindWidget.tsx # Ctrl+F find widget (pinned top-right) │ │ ├── reveal-line.ts # jump + landing-line highlight │ │ ├── tokenize.ts / markdown.tsx # read-only pane tokenizer / dependency-free markdown renderer │ ├── NativeExplorer.tsx# native page type definition + seat body (no-sidebar fallback) │ ├── composer.ts / use-remote-chat-target.ts / selection-popup.ts # remote @refs/selection-to-chat │ ├── api.ts # typed fetch wrapper over /api/dsh-remote │ ├── HostsView / WorkspacesView / WorkspaceWizard / TerminalView / XTerm / Dialog │ ├── SettingsHosts.tsx # settings occupants (host mgmt/binding list, self-fetching) │ ├── DirectoryFlow.tsx # native entry occupant (local dual-mode/remote wizard) │ ├── RemoteDirectoryPicker.tsx# remote directory browser dialog │ └── i18n.ts / theme.ts / ui.tsx # bilingual dictionary/theme signal/shared styles ├── vendor/dsh-remote-helpers/ # helper seeds (shipped, seeded into cache at startup, see below) ├── docs/ # local-only working docs (never pushed to GitHub) │ ├── P2-PLAN.md # P2 architecture/phases/acceptance matrix (with per-item status) │ └── MAINTENANCE.md # local-only (.gitignore), not shipped: DSH upgrade impact + helper procedure ├── locale/ # bilingual plugin metadata └── lib/ # build output (gitignored, restart after link install to take effect) ``` `vendor/dsh-remote-helpers/` (committed) holds helper seeds: at startup the plugin spreads them into `$DSH_HOME/dsh-remote-helpers/` (copy-if-absent, never overwrites), so fresh installs can push right away. When a new helper is built, swap the 4-piece set (tarball + sha256 + helper.json + patch notes) into vendor and commit (details live in the local-only `docs/MAINTENANCE.md` §4, which is gitignored and not distributed). `.spike/` (gitignored) holds live-host verification scripts plus the local self-checks: `p2-router.ts` main regression (real remote + real Cordis + real router classes) and probes like `grep-probe.mjs` (build with `tsconfig.scratch.json`), plus `model-check.mjs` / `reveal-check.mjs` / `fallback-check.mjs` / `client-load-check.mjs` / `tool-schema-check.mjs` which need no host at all (see "Development"). ## Remote workbench (VS Code style) Both surfaces share one state module (`store.tsx`): **the sidebar "Remote" page** (a standalone tab when `dsh-better-sidebar` is installed, otherwise the native right-Sidebar page) and **the conversation-view "remote editor" tab** (next to 对话/轨迹, giving the code area full width). - **Activity rail**: explorer / search / source control / commit graph / editor / terminal / workspaces. A panel stays mounted after its first visit, so switching back neither refetches nor flashes. While it is hidden it does **no remote work** (directory listings, the SCM stash/branch/remote queries and editor image reads all wait for the next foreground pass), and visibility is MEASURED on the plugin root (IntersectionObserver) instead of being taken from a host prop. - **Editor**: tabs plus an info bar (path, language, **build stamp**, `tok N` coloured-token count, saved state, reload/save). Those three marks exist for troubleshooting: the stamp answers "is the browser running the bundle I just built?", `tok 0` answers "did a grammar match this extension?". - **Find and jump**: `Ctrl+F` opens a widget pinned to the editor's top-right corner (`position: fixed` anchored to the editor's rect, so no ancestor scroller can carry it away); a selection is carried into it by `Ctrl+F`; a jump from the search results centres the line and lights it up. - **Terminals**: tabbed sessions with the session id persisted one by one, so a page refresh reattaches to the shells that are still running; **a reattach only takes over, it does not resize** (a live shell is never shrunk to 80×24 — the true size comes from the one tab that can measure itself, sent as a `resize` frame); a session is kept for **10 minutes** after its last socket goes away before the remote shell is closed (it used to be kept forever, so closing the browser left the shell holding an sshd session slot). - **Known limits (stated honestly)**: - There is **no public API to select the "remote editor" view**; the plugin can only click the shell's own tab by its label and toasts a hint when that fails (`revealEditor()`). - Untracked directories arrive as a **single `dir/` row**; clicking it only toasts instead of expanding (a deliberate performance trade-off). - Opening a file is capped at 8MiB (official helper constant); beyond that it is shown read-only. - The find widget floats above the code (like VS Code) and takes no layout space. - Output backpressure is **per shell**: one tab that cannot keep up pauses every tab attached to that shell — a deliberate trade, preferring to hold the remote command back on behalf of the slowest consumer over letting host memory grow without bound. - A directory listing past 20000 entries is truncated, and since the protocol has no "truncated" field the UI shows **no marker at all** — only the host log line `listing on shows 20000 of N entries`. ## P2 transparent remote execution (Windows native) Single-Host mixed routing: native tools dispatch by which world the session cwd lands in — remote aliases go over helper RPC (fs) / ssh2 (processes), local stays untouched; both share one Host and one GUI. - Alias dirs: `$DSH_HOME/remote-ssh/aliases/` (empty shells, routing keys only), auto-registered into the native workspace list as ` > ` - Routing switch (dropdown at the top of Settings → Remote hosts; underneath it is profile patch `- id: remote-vs`, `config.routingMode`, volatile, no restart): `off` (default, local passthrough) / `shadow` (logs, still local) / `enforce` (real routing) - New workspace: the native "Add workspace…" entry is adopted (local/remote dual tab; remote leg = host→push→bind wizard) - Presets: a `Remote` preset (bash toolset); under enforce, new remote sessions switch to it automatically (default preset only, never overrides an explicit choice) - Ground truths (measured, not assumed): - Remote `grep/glob` call remote `rg` through routing (both measured hosts ship rg 13; no remote rg means no hits) - Whole reads >8MiB report `FS_TOO_LARGE` (official helper ceiling) — use streaming/paged reads; **writes are uncapped** (a 46MiB whole write passed live) - Oversized process output lands in a Host temp spill file (`readFrom` returns tail + path, full text lives in the file — same as stock) - After unbinding, new calls are explicitly refused (fail-closed tombstones), history stays readable - Disconnects: in-flight calls are not replayed; new calls reconnect automatically - Verify: `npx tsc -p tsconfig.scratch.json && node .spike/build/.spike/p2-router.js` (real remote + real Cordis + real router classes, 61/61; P0 routing and GUI need a Host restart + smoke pass) - Details: `docs/P2-PLAN.md` (§8 acceptance matrix with per-item status) ## Self-built helper (P1) Upstream never published helper binaries (all Release assets are empty), so this plugin builds its own: - Build machine: WSL2 Ubuntu-22.04 (glibc 2.35, matching the remote targets), Node v24.21.0 + pnpm 11.28.5 - Sources: the `dsh-v0.2.1-alpha.2` tag (the **build baseline**: what must match is the wire protocol version, not the Host version — it is still 2, so a Host upgrade does not require a rebuild) - Command: stock script `pnpm exec tsx scripts/build-exe-for-ssh-helper.ts --target=node24-linux-x64` - Only deviation: no docker for the manylinux 2.28 rebuild, so a `DSH_GLIBC_CEILING` env override was added (default stays 2.28); this build targets 2.35 (see `glibc-ceiling.patch` in the cache dir) - Checks: stock `verify-ssh-helper-artifact.ts --sandbox=required`, all 8 green - Cache: `$DSH_HOME/dsh-remote-helpers/` (`*.tar.gz` + `.sha256` + `.helper.json` with binarySha256, feeding P2's helperHash) - Full rebuild procedure for upgrades: the local-only `docs/MAINTENANCE.md` §4 (gitignored, not distributed) ## Security model - All `/api/dsh-remote/*` routes are loopback + same-origin only. - Passwords / key passphrases are stored in plaintext in `dsh-remote.json` (0600) — same trust model as the existing SSH plugin. - Pushed artifacts are sha256-verified both ways (tarball in, binary out); the helper install dir is separate from workspaces. - Remote sessions may read explicit local absolute paths (the tool spill-file contract requires it); acceptable under the same model trust — see `docs/P2-PLAN.md` §4 note. ## Development ```sh pnpm install pnpm run typecheck pnpm run build dsh plugin --profile web add link: ``` **Restart `dsh web` after every change — the client half included.** The Host reads plugin bundles into memory at startup (`initialBundleSnapshot`; the served URL carries a `rev` and is `immutable` for a year), so a browser refresh — `Ctrl+Shift+R` included — only re-fetches the bytes captured at startup. How to tell: 1. Each build injects a stamp (`MMDD-HHmm`) into `lib/client.js`, shown at the right of the editor info bar; 2. Restart `dsh web` → refresh the page: **the stamp must change** for the change to be live; 3. For colour issues, read `tok N`: `0` means no grammar matched the extension AND the fallback colorer did not take over (the console line next to it names the file). Self-checks (no host, no DSH, a few seconds each): ```sh node .spike/client-load-check.mjs # really evaluates the client modules: load-time errors tsc/bundler/other harnesses miss node .spike/tool-schema-check.mjs # all 5 agent tools: run execute, then validate the result with the RUNTIME's own validator node .spike/model-check.mjs # 55 checks: real git output → parsers, diff model, hunk patches, class↔CSS contract node .spike/reveal-check.mjs # 5 checks: jump target, landing highlight, repeat jump in one editor node .spike/fallback-check.mjs # 12 checks: the fallback colorer colours java/cpp/shell in a real DOM node .spike/editor-dom-check.mjs # 12 checks: a real CodeMirror in jsdom emits colour spans for java/shell/cpp node .spike/i18n-check.mjs # zh/en dictionary consistency: no missing key, no orphan, matching placeholders node .spike/ref-check.mjs # 32 cases: assertRef accepts real revision suffixes, rejects every injection surface node .spike/status-check.mjs # runs the generated git commands in a real shell to prove exit codes are no longer masked node .spike/cache-check.mjs # incremental token cache vs whole-document tokenizing, 9 languages × 8 edit shapes node .spike/layout-check.mjs # height chain + scrollbar contract: header rows stay pinned, the bar is wide enough to grab node .spike/viewstate-check.mjs # scroll+caret round trip across a remount (stateful scrollTop: tells "restored" from "reset") node .spike/tabswitch-check.mjs # real React drives three wirings: both ref-reading ones must fail, reporting must pass node .spike/letter-check.mjs # status letters: untracked `?` no longer collides with conflict `U`, plus both render sites ``` `.spike/layout-fixture.html` (generated by `build-fixture.mjs`) is the fixture for measuring layout in a browser: it mechanically inlines the **host's real slot CSS** and the **shipped vsc.css**, so after a CSS change you re-run `node .spike/build-fixture.mjs` and can measure the height chain, the scroll container and the scrollbar width for real. Pure-CSS defects are invisible to tsc, to the bundler and to jsdom — this fixture is the only place they reproduce. `markdown-check.ts` has to be compiled first (it imports a `.tsx`, which Node cannot load): `npx tsc -p tsconfig.scratch.json && node .spike/build/.spike/markdown-check.js`. > `.spike/` is git-ignored, so these checks are local only and run in no CI. **Run them before quoting any > number** rather than copying a figure out of any file — one `model-check` count drifted across the README, > the local maintenance doc and the review reports as 44 / 48 / 55, while the harness actually prints 55/55 > (its source holds 57 `check(` call sites, 2 of them inside loops and reachable only on failure, which is > exactly why "57 call sites" and "55 checks run" differ by 2). The live-host regression is still `npx tsc -p tsconfig.scratch.json && node .spike/build/.spike/p2-router.js`. Before touching anything for a new DSH release, read the local-only `docs/MAINTENANCE.md` (§2 coupling points, §3 checklist) — it is gitignored, so a clone will not have it. The mandatory steps after a host-half change are `pnpm run verify:lib` plus the load self-check. ## License MIT © 2026 xiray-xia — see [LICENSE](./LICENSE). The `vendor/` helper seed is built from DeepSeek Harness sources (MIT) at tag `dsh-v0.2.1-alpha.2` with the stock build script; see "Self-built helper" above for the exact procedure and the single documented deviation (`glibc-ceiling.patch` in the seed set).