Back to catalog

omdsh-plugins /

omdsh-remdev

Verified

Remote development for the DeepSeek Harness web GUI: connect a workspace to an SSH server, provision a .dsh-server there, and run its files, terminals, and agents on that machine

0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
READMESource: main@d2dd7862

omdsh-remdev

English | 中文

Remote development for the DeepSeek Harness web GUI: attach a workspace to an SSH server, and its files, its terminal, and its agent all run there.

What it adds

Surface Where it comes from
A Remote connect button at the trailing end of the sidebar's Workspaces bar An entry in shell.overlay that renders nothing at its own seat and portals into [data-slot="sidebar.workspaces"]
The Remote development window — servers, provisioning, the folder picker, the live log src/client/RemoteDialog.tsx, over the /omdsh-remdev prefix route, fenced to the same trusted hosts as /api
A globe badge on every remote workspace row Portalled into [role="treeitem"][aria-expanded] and positioned from a measurement
The remdev service — remoteFor(cwd) ctx.reflect.provide, the one question omdsh-sidepanel and omdsh-code ask before reading a file or starting a process
A mounted project's skills (.dsh/skills, .agents/skills) src/remote-skills.ts, a provider registered on the harness's own skills service that reads SKILL.md files back over SFTP
A remote workspace that is an ordinary workspace A real mirror directory under $DSH_HOME/remotes/<server>/<project>, which is what workspaces.create was always asking for
Conversations held on a server, listed and resumable here The session mirror, which copies the remote logs down and rewrites exactly one header field
The omdsh-remdev settings namespace ctx.inject(['settings']), with the servers and their credentials in a hidden role('secret') store
The remdev.connect shortcut command Registered with ctx.shortcut when a keybinding layer is composed; the default chord is CmdOrCtrl+Shift+C (⌥⌘C in a tab), decided by omdsh-shortcuts' document — see src/client/shortcut.ts

A Remote connect button at the trailing end of the sidebar's Workspaces bar opens one window: describe a machine, connect to it, install the harness on it, pick a directory. What comes back is an ordinary workspace with a globe on its folder — the same rows, the same sessions, the same Code mode as a local project, on somebody else's computer. The same press is on the keyboard too: with omdsh-shortcuts composed, ⌘⇧C (⌥⌘C in a browser tab) opens the window, and the button's tooltip names the chord.

Nothing about local development changes. A composition without this plugin resolves no service; a workspace that is an ordinary directory resolves no mount. Both fall through the branch that was always there.

How a remote workspace is an ordinary workspace

workspaces.create canonicalizes its argument with realpath and requires a directory to be there. That is not an obstacle to route around — it is the harness saying a workspace is a place on this machine — so this plugin gives it one: a real, empty mirror directory under $DSH_HOME/remotes/<server>/<project>, holding nothing but a README.txt that explains itself to whoever finds it.

Everything downstream then works untouched. The session store groups by that directory, the registry accounts by it, the projections key on it, the usage roll-up sums by it. The only code that ever learns the directory is a stand-in is the code that was about to read a file or start a process in it, and it learns by asking one question:

const remote = ctx.get('remdev')?.remoteFor(cwd)
const listing = remote === undefined
  ? await listLevel(cwd, path)      // exactly as before
  : await remote.list(path)         // the same listing, over SSH

That is the whole of the seam. omdsh-sidepanel and omdsh-code each gained one structural view of it and one branch; neither imports a value from this package, and neither knows what SSH is.

A mounted project's skills

The harness discovers skills from the session's cwd: its own filesystem provider walks <project>/.dsh/skills and <project>/.agents/skills through the LOCAL filesystem service. A remote workspace's cwd is its mirror directory — an empty local stand-in — so the server's project skills are invisible to an agent running here. This plugin registers a provider (remdev) on the harness's skills service to close exactly that gap: when the cwd resolves to a mount, it reads the two skill directories back over SFTP into the same catalog — directory bundles and flat Markdown skills alike, parsed with the harness's own frontmatter grammar (name, description, whenToUse, invocation policy, metadata).

Four things are worth writing down:

  • It reads two fixed paths inside the mount. The fence is the same one the file browser uses; a skill can never reach anything outside the mounted directory, and an unreadable skill file is skipped with a log line rather than failing the rest.
  • A skill's resourceBase is opaque, not directory. The templates and scripts a skill references live on the server, where the local file tools cannot read them, so the model is told where they are and how to get there instead of a path its read tool would fail on. To use those resources, open the remote file panel or a remote terminal; a Code-mode agent already runs on the server and reads the same .dsh/skills locally there.
  • Remote changes are not watched. The catalog re-reads when the registry's cache invalidates — a changed mount table (attached or detached folders, edited servers) does that — so a skill added on the server appears on the next refresh, and the same goes for a skill file edited mid-session.
  • An unreachable server simply contributes no skills. The harness marks the observation incomplete and retries on a later boundary; user skills in the local ~/.dsh/skills are unaffected.

.dsh-server

The layout is .vscode-server's, for the reason VS Code chose it — a remote install has to survive a server having its own Node, the wrong one, none, or several:

<dshHome>/.dsh-server/
  bin/dsh              the launcher every terminal actually runs
  node/<version>/      a private Node, present only when the system one is unusable
  tools/pnpm/<v>/      a private pnpm, present only when the system one is unusable
  tools/bin/           shims for the above, on the launcher's PATH
  cli/<version>/       npm prefix holding @deepseek-ai/dsh and its graph
  data/                DSH_HOME for the remote harness: profiles, sessions, storages
  install.log          the last provisioning run, for when somebody asks why

bin/dsh is the whole contract with the rest of the plugin. Terminals run that path and nothing else, so which Node was chosen and which release was installed are decided once and repeated nowhere.

Two ways in. The server downloads Node and installs the package itself; failing that, this machine stages both and pushes them over SFTP. The fallback exists because the servers people most want this for — an internal cluster node, a machine behind a jump host — are exactly the ones with no route to nodejs.org. It moves ~100MB, so it is never the first choice and always the second.

pnpm is vendored too, for a reason worth writing down. dsh plugin is a thin forwarder: it spawns pnpm by name off PATH and cannot be pointed anywhere else. Node's own tarball ships node, npm, npx and corepack — and no pnpm. So the very servers this plugin vendors a Node onto are exactly the servers that then cannot be given a terminal application: dsh: pnpm not found on PATH, exit 127, and a Code profile left holding nothing but dsh-base. A pnpm goes under tools/pnpm/<version> by the same two routes as Node, with a shim at tools/bin/pnpm that the launcher puts on PATH.

The install is an unpack, not an npm install -g, and both halves matter. pnpm publishes with no dependencies and its whole runtime in dist/, so untarring it is installing it — which keeps npm's ~500MB dependency-resolution heap off a small server, on a 1GB box that has already been watched to die of exactly that. And it lands under .dsh-server rather than in a global prefix, because npm i -g with a system Node writes into /usr/lib/node_modules: root's to give, nobody's to take back. Removing .dsh-server still removes everything. A server with a pnpm 9 or newer of its own keeps it and downloads nothing.

The shim execs the chosen Node by absolute path rather than letting #!/usr/bin/env node resolve — otherwise a vendored pnpm ends up running on the very system Node that was too old to run the harness.

Everything is asked through a login shell — one that starts. ssh host command runs non-interactively and non-login, and on a real development server that is a different PATH from the one a person sees when they log in — nvm, conda, homebrew, and every module load system put their toolchain there. Measured through a plain sshd: sh -c finds no node, sh -lc finds it. Without the -l, a server with a perfectly good Node 22 is probed as having none and gets a second copy downloaded onto it.

Which login shell is the part that bites. /etc/profile sources every /etc/profile.d/*.sh unconditionally, those files get written by people who tested them in bash, and /bin/sh on a Debian-family server is dash — where one function foo() {} is a syntax error, which POSIX says a non-interactive shell must exit on. Not a warning, not a skipped file: sh -lc exits 2 while sourcing the profile and the script never runs. So the shell is chosen on the server, by asking each candidate to start a login session and say nothing: $SHELL when it is a POSIX one, then bash, then sh, then plain sh -c with no login at all. The payload appears once in that command and runs once, so an npm install cannot be run twice by a fallback. A degraded PATH is worth strictly more than a connection that cannot be probed.

The probe prints a marker line before its own output and the parser reads the fields after the last one, because a login shell runs the machine's profiles and profiles talk — a conda banner ahead of the answer would otherwise be read as the server's home directory.

Code mode needs a terminal application. The harness ships three profile bundles and none of them is an interactive terminal, so provisioning also creates a profile (dsh-code by default) composing @deepseek-ai/dsh-base with whatever codeBundle names — @omdsh-plugins/omdsh-tui-app unless you say otherwise. A server that cannot fetch it is still a server whose files, shell, and mirrored conversations all work — the failure is logged and the install continues, because refusing the whole thing over the one part that did not is a bad trade.

The omdsh-tui terminal is its own install, from GitHub. omdsh-tui publishes nothing — no npm package, no releases, no tags — every install is a checkout install, so the server's ready row carries an Install omdsh-tui button that does the whole thing: fetch the default branch's current head, install the dependencies, build, link both packages (@omdsh-plugins/omdsh-tui and @omdsh-plugins/omdsh-tui-app) into the omdsh-tui profile, and then prove it — the profile must compose with the bundle's layer in --dump-config, and every module a loader row names must resolve from the profile directory, which is the check that catches a tree that dumps perfectly and dies at boot. A run that says ready is a bootable terminal; one that does not, fails the install and says why in the log.

The same two routes as everything else: a server with internet fetches and builds on its own; one without gets the source downloaded, installed and built on this machine — with pnpm's hoisted node linker, so the tree that goes up holds real files rather than symlinks into this machine's store — and the built tree pushed over SFTP. Pressing the button again is an update: the checkout is replaced with the branch's current head and the verification is re-made. The checkout lives under .dsh-server/tui/, so removing .dsh-server removes it too.

Conversations come home

A conversation held on a server is written by the harness running there. This plugin copies those logs down so the local app can list, search, title, and count them with no change to any of the code that does those things.

The copy rewrites exactly one field of exactly one line: the header's working directory, which locally must name the mirror rather than the remote path — the workspace registry accounts a session by resolving that cwd with realpath and requiring a directory to be there. Everything else is byte-identical, including the session id, which is what makes the row clickable: opening it runs the remote launcher with --session-id <that id> and the remote harness continues the conversation it already has.

Two details are load-bearing and both were found by running it rather than by reading:

  • The file is written where the local backend says a session with that header belongs (sessionPersistence.locate), in the local backend's own encoding. Dropping a plain .jsonl into a store configured for zstd raises an encoding-mismatch error from list, which takes down the whole session list rather than the one session.
  • A Zstandard session log's first frame must decode to exactly one line. Writing the whole log as one frame does not corrupt one session — list throws, the workspace registry fails to initialize, and the application stops booting.

The mirror is one-way and is not a merge. The remote is the only writer; the local copy is replaced wholesale each time it changes, which is why nothing offers to edit it.

The log

Every server has one, and it lives on the host rather than in the browser.

That is not an implementation detail — it is the difference between a pane that works and one that does not. A log held in the page is empty after a reload, empty in a second window, empty for anything that happened before the dialog was opened, and — because every line went into one list — mixed two servers' output together. The host is the party actually doing the work, it does it whether or not anybody is looking, and it outlives every browser that watches. So a dialog opened at any moment asks what has happened and gets the answer, including an install that started ten minutes ago in a window that has since been closed.

It records the whole lifecycle, not just installs: connecting, connected, what the probe found, disconnections and why, every line of a provisioning run, folders attached and detached, conversations copied. Lines are stamped and levelled by the host, because the host is the party that knows when a thing happened and whether it went wrong — a browser that joined halfway through knows neither. A run that has been sitting on the same npm line for two minutes looks identical to a fast one until the clock beside it says otherwise.

One bounded ring per server, 500 lines deep — a complete provisioning run fits, and the next one pushes the previous out, which is the right thing to keep. Nothing is persisted: a log describes what this process did, and a stored one would describe what some earlier process did, which is a different and much less useful claim.

Credentials

Three ways to authenticate, chosen per server: the running ssh-agent, a private key, or a password.

A private key comes from one of two places, and both exist because they answer different situations:

  • A key file on this machine — a path on whatever machine is running dsh. Right when the key is already there, which for a local install is the ordinary case, and the key never leaves the disk it was already on. A leading ~ is expanded, because that is how everybody writes the path to their key.
  • Paste the key — the key text itself, stored with the connection. The only route in when the key is not a file on the dsh host: a key somebody handed you, a key out of a password manager, or a browser on one machine driving a dsh host on another. A paste that lost its trailing newline still works — OpenSSH's writers add one and every parser expects it, so it is put back rather than reported as a malformed key.

An encrypted key takes its passphrase in the same box a password would use. Both credential boxes are write-only: an existing one arrives at the browser as hasSecret / hasPrivateKey and nothing else, so leaving a box blank keeps what is stored and clearing it deliberately removes it. Neither ever travels back out of the host process — not through this plugin's own routes, which send a view with the credentials removed, and not through the settings wire either, because the store they live in is declared role('secret') and the settings service strips it from every description before it crosses to a browser. A key file is read from disk at dial time and lives in memory for exactly as long as the dial takes.

Changing a credential drops the live connection rather than keeping one authenticated with the old one — the connection pool keys on a hash of the stored credentials, not on whether one exists.

Reaching the model from a server

A remote agent is a fresh login on another machine and inherits nothing, so the model credentials a local Code terminal takes for granted are simply absent there. The variables named in forwardEnv (DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL by default) are read from this process when a terminal opens and exported into the remote shell.

export, not env KEY=value cmd, and that is a security decision rather than a style one: an env prefix puts every value in the command's argv, where ps shows it to every other user on the box. The only argv that ever exists on the server is the launcher's own — a path, a profile name, and a session id, none of them secret.

A server with its own credentials configured needs none of this; empty the list to send nothing.

The route it holds

One prefix route, /omdsh-remdev, registered on ctx.webServer. Every call the window makes rides it: the state read, saving and deleting a server, probing one, provisioning it, installing the omdsh-tui terminal on it, browsing its filesystem for the folder picker, attaching and detaching a folder, reading the log, asking for a sync — plus one server-sent events stream that carries state changes and new log lines to every open window.

It is fenced exactly as /api is, and for the same reason. Every request is checked against the harness's own trustedHosts before it is answered; anything else gets a 403 and nothing else. A route that stores SSH credentials and installs software on other people's machines must be exactly as reachable as the harness's own control plane and no more — and the fence is the harness's list rather than a second one written here, so a deployment that tightens trustedHosts tightens this with it.

Everything but the state read is a POST, including the log read. A GET that installs software on a server is a GET that is reachable from a link, and a log naming somebody's servers is not a thing to leave in a URL.

What crosses the route is a view with the credentials removed: the browser learns hasSecret and hasPrivateKey and never a byte of either.

Where the surfaces come from

The harness declares no slot inside the sidebar's workspace region — the whole region is one single slot that ui-workspace fills, and everything in it is that package's private composition. So both surfaces ride shell.overlay, render nothing at their own seat, and portal into anchors they find:

  • [data-slot="sidebar.workspaces"] — the slot renderer's published contract. Its single element child is the region root; the root's first child is the Workspaces bar, checked by height rather than assumed.
  • [role="treeitem"][aria-expanded] — a workspace row. Session rows are treeitems too and carry aria-selected instead; reading a label off one answers with its timestamp.

Class names are not available to depend on even if that were wise: every class in that package is a hashed CSS-module name.

The badge is portalled into the row, not into the folder icon, and positioned from a measurement. The row hides its folder and shows an expand chevron under :hover — so a badge living inside the folder would vanish under the pointer, which is exactly when its tooltip is being read.

When an anchor is missing, nothing renders. Not a fallback position, not a floating button in a corner. A button that appears in the wrong place is worse than a button that does not appear: one is a feature you cannot reach, the other is a feature that breaks the app around it.

Configuration

One settings namespace, omdsh-remdev. The servers and mounts live in a hidden store written by this plugin's own dialog — a list of objects the generic form refuses to draw, holding credentials no form should render. It is declared role('secret') as well as hidden, and the two do different jobs: hidden keeps it out of a form, the role keeps it off the wire. Everything else is a plain field the panel draws:

Field Default What it decides
dshVersion latest which harness release to install on a server
nodeVersion v22.20.0 which Node to install on a server whose own is older than 22
pnpmVersion 11.7.0 which pnpm to install on a server that has none; the harness installs profile plugins with it
allowUpload true whether a server with no internet may be set up by uploading from here
syncIntervalSeconds 60 how often conversations are copied down; 0 switches the background copy off
codeProfile dsh-code the profile name a Code terminal boots on a server
codeBundle @omdsh-plugins/omdsh-tui-app the terminal application that profile composes; space-separated for an application that ships as more than one package
tuiRepo https://github.com/omdsh-plugins/omdsh-tui.git the git repository the omdsh-tui terminal is installed from; point it at a mirror when servers cannot reach github.com
tuiProfile omdsh-tui the profile the omdsh-tui terminal installs into; the remote Code column boots it
forwardEnv DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL environment copied from this machine onto a remote agent

Every field is applies: 'live': an edit is adopted as it commits, and the next dial, provisioning run, terminal or sync uses it. Nothing here asks for a restart. The versions are read when a server is set up, so changing one re-provisions the next server rather than the ones already installed.

Install

dsh plugin --profile web add @omdsh-plugins/omdsh-remdev

Or from a checkout:

pnpm install && pnpm run build
dsh plugin --profile web add "$PWD"

Remove it the same way:

dsh plugin --profile web remove @omdsh-plugins/omdsh-remdev

A remove takes the button, the window, the badges, the service and the route with it. The mirror directories under $DSH_HOME/remotes stay where they are, and so do the conversations already copied into them — they are ordinary directories and ordinary sessions, which is the whole point of the design.

Nothing here is a companion plugin's precondition, in either direction. omdsh-sidepanel and omdsh-code gain their remote branches automatically when this plugin is present, and neither requires it — each takes the lazy read rule 9 describes, ctx.get('remdev') at the moment a directory has to be resolved, and answers for itself when it comes back undefined.

The reverse is the same trade, and it is worth saying what it costs. This plugin's own inject names only harness services (webServer, webRuntime from the web surface bundle; slots, workspaces, locale in the browser), so a profile with neither sibling installed boots and this plugin works: the button, the window, the mirror directories, the workspace rows, the badges, and the conversation mirror are all its own. What is missing is what those plugins draw — with no omdsh-sidepanel there is no file explorer for the remote tree to appear in, and with no omdsh-code there is no terminal to open on the server. The remote workspace is still a workspace, and its conversations still list, search, and resume.

Commands

pnpm install
pnpm run build       # tsc emits lib/types, tsdown bundles both halves
pnpm run typecheck   # sources and tests
pnpm run test        # vitest

Known limitations

  • The surfaces borrow their anchors, and a missing anchor renders nothing. Both the button and the badges portal into the sidebar's own DOM; a row shape the harness changes is a selector this package has to follow, and until it does, the feature is absent rather than misplaced.
  • The conversation mirror is one-way. The remote is the only writer and the local copy is replaced wholesale, so nothing here offers to edit a mirrored conversation, and a local edit would be overwritten on the next sync.
  • The remote skill catalog is not live. The server's .dsh/skills is re-read when the registry's cache invalidates — there is no file watching — so a freshly added skill appears after the next invalidation rather than immediately.
  • The log is 500 lines and does not survive the process. A provisioning run fits; a week of them does not, and a restart starts every ring empty on purpose.
  • A server that cannot fetch codeBundle gets no Code mode. Files, terminals and conversation sync all still work — the failure is logged and provisioning continues — but the profile it created has no terminal application in it.
  • Installing omdsh-tui needs pnpm in one of the two places. On the server it is the vendored tools/bin/pnpm shim the provisioning run arranges; for the local-upload fallback it is this machine's own pnpm, or a pnpm fetched through npx, and a machine with neither reports that instead of guessing.
  • The omdsh-tui install is a whole build, not a file copy. The dependency install and the tsdown build run on whichever side fetches the source, so an install takes minutes and an update takes the same minutes again — which is what installing from source means, and why the button is a thing you press rather than a step every provisioning run repeats.
  • The server has to be POSIX. Every remote path is built with node:path/posix and every probe runs through a POSIX login shell, so a Windows server is not supported. A Windows machine running dsh locally is fine — reaching for posix explicitly is exactly what makes it fine.
  • A directory level is capped at 1000 entries. Past that the listing is returned marked truncated rather than paged.
/ 5

No ratings yet

Community comments

No comments yet. Be the first to write one.