omdsh-remdev
English | 中文
Remote development for the DeepSeek Harness web GUI: attach a workspace to an SSH server, and its files, its terminals, its conversations and its agents all run there — Work mode included, because Work mode is the harness on that machine, shown here.
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 |
| The Plugin hub card — the server list, and the drawable tunables grouped by install / Code / remote window / conversations / credentials | An entry in omdsh.plugin.card whose id is the package name; it replaces the generic form, which cannot draw the server list and would present twelve knobs as equals |
| On the server — a button in the session header of any conversation whose project is mounted, which is the one place Work mode could otherwise be quietly local | An entry in conversation.session.header.utilities, ui-conversation's own utility row (src/client/RemoteEnter.tsx) |
| The remote window — this app handed over to one server, Work and Code both running on it | dsh --profile web started through the remote launcher, a tunnel over the SSH connection already open, and a loopback listener here (src/remote-web.ts, src/web-proxy.ts, src/client/RemoteWindow.tsx) |
| 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-codemode 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: pick a host, connect (the harness is installed if the machine does not have one), open a folder. 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-codemode 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
resourceBaseisopaque, notdirectory. 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 itsreadtool 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/skillslocally 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/skillsare 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.
Failing has to be something the first route can actually do. A download that fails is uninteresting: the fallback runs and the whole thing takes seconds. A download that hangs is the real case, and it is the normal condition on a machine behind a firewall that drops packets rather than refusing them — the connection to the server is healthy, so keepalives see nothing wrong, and curl's own defaults are 300 seconds to connect and then forever to transfer while wget waits forever at both ends and retries twenty times. So every download carries --connect-timeout and --max-time (--timeout and --tries=1 for wget), and every command that reaches the network carries a backstop clock in the transport behind those flags. Without them the fallback is unreachable in practice on exactly the servers it was written for.
And a person can say it outright. Ticking Upload from this machine beside Connect skips every server-side download and stages the lot here. It changes nothing about what gets installed — only which route is tried first — and it exists because waiting through four steps' worth of timeouts to prove something you already knew is its own kind of failure. tuiLocalPath goes one further: it names an omdsh-tui checkout on this machine to build and push, which is the only arrangement that works when neither end has a route.
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, and nobody should have to ask for one. 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 — and installs it. There is no separate button for the terminal and no separate profile for it to live in: a server that has been set up has a terminal on it, or a reason it has none.
Which install runs is decided by where the package comes from. A terminal your registry carries is added with dsh plugin add, run THROUGH the launcher so it lands in the right DSH_HOME, on the right Node, and on the PATH the vendored pnpm shim is on. The default one is carried nowhere — omdsh-tui publishes nothing: no npm package, no releases, no tags — so it is installed the way its own README says to, as a checkout install: 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 Code 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.
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. The checkout lives under .dsh-server/tui/, so removing .dsh-server removes it too.
"Upload from this machine" moves the build, not the download. The source still comes from GitHub — this machine fetches it instead of the server — so a tick in that box does not make the install offline. And the URL is the most rate-limited thing an unauthenticated client can ask GitHub for: a tarball generated on demand for an unpinned ref. Pressing Reinstall a few times in a few minutes is enough to be answered HTTP 429.
Which used to end the install outright, with a perfectly good copy of the source sitting in the staging directory beside it. Now a refusal that means "ask again" — 429, any 5xx, a dropped connection — is waited out and retried up to three times, honouring Retry-After where the server sends one and capping it so a bad day cannot park an install for an hour. If it still cannot be had, the previously staged tarball is used and the log names the day it was downloaded: yesterday's terminal is worth immeasurably more than no Code mode, and nobody should have to wonder which source they are looking at. It is never preferred — Reinstall means "get the latest", and this runs only when the latest cannot be had.
tuiLocalPath is the way to stop depending on that download at all: point it at a checkout on this machine and nothing is fetched from anywhere.
A server that is missing its terminal is noticed by the thing that needs one. Opening a remote Code terminal asks the profile what it composes — one cat of its manifest, once per server per process — and a profile holding dsh-base and nothing else gets the terminal installed rather than a terminal started that would die at boot. The TERMINAL, not the server: provisioning resolves dshVersion first, so making a full run the repair would have opening a Code column silently upgrade the harness on that machine. That check is what the bundle ROWS are for: a profile whose creation succeeded and whose install then failed looks exactly like a finished one from the outside, and treating it as finished is what leaves the one machine that needs an install the one machine that never gets one.
What that costs is bounded on purpose. An automatic run installs only what is missing, so a server that already has its terminal never rebuilds one. A failed attempt is remembered as a failed attempt: the next terminal is refused with the reason rather than starting minutes of failing install all over again. And the failure never takes the rest down with it — files, shells and mirrored conversations do not need a terminal, so provisioning finishes and reports.
Reinstall on a ready server is the update. It is the one run a person asked for, so it re-installs the terminal whether or not one is there: the checkout is replaced with the branch's current head, the profile re-linked, the verification re-made — and for a registry terminal, resolved again from your registry. That is why the button is worth pressing on a server that looks fine, and why it takes minutes when it does.
Work mode runs on the server
Everything else here makes a LOCAL harness reach a remote machine: files over SFTP, terminals over a pty, a project's skills over both. Work mode cannot be done that way, and for a while this plugin pretended otherwise.
A Work conversation IS the harness's agent loop — its read, its edit, its bash, its session log. On a mounted workspace that loop was running here, in the mirror directory, which is an empty local stand-in: glob found one README.txt, bash ran on the laptop, and an edit landed on a file the server had never heard of. Code mode had been remote since the beginning (it starts the remote launcher), so the two halves of the same workspace disagreed about which computer they were on.
So the harness that serves Work on a mounted project is the one on that machine. There are two ways in and they mean the same thing: On the server, in the session header of a conversation whose project is mounted — where the question actually comes up — and Enter server on that machine's row in the Remote development window. Either one hands the screen to it: dsh --profile web runs there, bound to that machine's own loopback; a tunnel rides the SSH connection that is already open; a loopback listener here is what a browser points at. What fills the frame is the server's own dsh — its conversations, its Work column, its files, its agent, its terminal — and the strip along the top is the only part of the screen that is still local: which machine you are on, and the way back out.
That is VS Code's arrangement, for VS Code's reason: the work happens where the code is, and the interface happens where the person is.
The server needs nothing installed for it. web is one of the harness's own shipped profile templates (dsh-base plus dsh-web-app, both in-box), and booting a profile that does not exist yet initializes it from that template. So a server this plugin has provisioned can already serve the GUI: no bundle to add, no package to fetch, no registry to reach. It is the one remote surface with no install step — which is also why entering a server does not ask for the terminal application Code mode needs, and works on a machine whose omdsh-tui build failed.
The port is read, not chosen. --port 0 lets the OS pick, and the harness prints dsh web: http://127.0.0.1:<port> once its Loader has settled — after the /api route owner has mounted, which makes that line a readiness signal as well as an address. Asking for a fixed port would collide with whatever else that machine runs; parsing the line gets both facts at once. A login shell is what runs the launcher, for the reason everything else here does, and the parse ignores every other line, because a login profile talks and a MOTD carrying a URL is not the answer.
Nothing is rewritten in flight, and that is a security property rather than a shortcut. The harness fences /api on the Host header — loopback, or a declared trustedHosts authority — so a proxy onto a public name has to rewrite Host and Origin to get past it. This listener binds 127.0.0.1, so the authority a browser fills in is already a loopback one on the far side, and the page's own requests are same-origin against it. Measured through a real dsh --profile web: workspace.list and workspace.create answer ok through the tunnel on a different loopback port, and both WebSocket downlinks upgrade. The proxy therefore forwards TCP and parses nothing.
What it does add is worth stating plainly: while a window is open, any process on this machine can reach that server's harness through that port — the same access any local process already has to the local harness on 127.0.0.1:3080. It is the harness's own fence rather than a weaker one, the port is never reachable from the network, and it exists only while somebody is working on that server.
A press that cannot get in sends you where the reason is. The header button has room for a glyph and two words, so it does not try to report a failure: it opens the Remote development window on that server, where the refusal and the whole install log already are. The window's own Enter reports inline, because it is in a form that has somewhere to put a sentence. One action, two ways of explaining itself, and the same starting flag on both — so pressing one and looking at the other shows work in progress rather than a control that appears to have done nothing.
The folders you attached here are listed there. A server whose harness has never run has an empty workspace list, so entering it would land on a "pick a directory" screen for directories you have already chosen. Each mount is registered on the remote with its own workspace.create — the same call this app makes when a folder is attached — through the very door the browser is about to use, which turns a broken tunnel into a message instead of a blank frame. A refusal is one line in the log and nothing more; the window works either way.
Every way it can end, ends the same way. The channel the remote harness runs on IS its lifetime: closing it hangs the process up. So leaving the window, deleting the server, dropping the connection, unloading the plugin and the idle sweep all reach the same place, and none of them can leave a harness running on a machine nobody is using. A window nobody is connected to is closed after windowIdleMinutes (30 by default), which needs both conditions — an open page holds two WebSocket downlinks, so "nothing connected" alone would reap a window somebody is reading. If the harness stops for a reason of its own, the frame goes with it rather than sitting on a door onto nothing: the state a browser renders comes from the host, which is the party that noticed.
A harness that refuses to start is quoted, and a SILENT one is diagnosed. An unbootable profile, a missing launcher or a bad flag all announce themselves, and those words are what the failure says. Exiting non-zero having printed nothing is a different thing and a real one: a .dsh-server staged on one platform and pushed to another holds native modules for the wrong machine, and the first native call dies on SIGBUS with no output at all. So a silent exit reports its status and what does that, and points at Reinstall — which is the button such a server actually has: a mismatched tree installed fine, the launcher answers, the probe reports a version, so the server reads ready, and ready is the phase whose only install control is Reinstall. A run somebody presses replaces a tree resolved for the wrong platform rather than reporting it already installed. One caveat if you want the exact version: a replacement staged from this machine is only correct when the upload can aim npm at the server, so on a server that can reach the registry, untick Upload from this machine before pressing it.
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.jsonlinto a store configured for zstd raises an encoding-mismatch error fromlist, 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 —
listthrows, 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 server's own environment
Every server has a box for KEY=value lines, on the same form as its host and its user, and everything this app runs on that machine gets them first. It is where http_proxy goes.
Per server rather than one list for all of them, because that is what the value is: a proxy is a property of where a machine sits on somebody's network, and two servers in one deployment routinely want different answers — or one of them none at all.
Everything, and that word is the feature. It is applied by the SSH transport rather than by each of the thirty-odd places that run something, so it reaches:
- the setup run — the Node tarball,
npm install, the pnpm tarball, the omdsh-tui source, the profile install. This is the one that matters most: a server whose only route out is a proxy is otherwise a server with no route out, and the fallback that rescues it is a 100MB upload from your machine. - Work conversations and the Code terminal, both of which are the remote harness started through the launcher.
- the shell in the bottom panel, and anything you go on to run in it.
It does not reach SFTP, which has no shell and needs none: listing files, previewing them, and copying conversations home all ride the SSH connection itself.
export, not env KEY=value cmd, for the reason above — a proxy URL routinely carries a password, and an env prefix would put it where ps shows it to every other user on the box. Values are quoted whole, so a space, a $ or a quote arrives as typed rather than as shell source.
A value you typed beats one the machine sets. A login shell sources every /etc/profile.d/*.sh, and a proxy helper in there that exports http_proxy unconditionally is a real thing on a real server — so the environment is applied again inside that shell, after the profiles have had their say. The same order holds in a terminal, and against the variables forwardEnv copies from this machine: those are generic defaults, and this is a fact about one server.
The box takes a paste. A leading export, # comments, blank lines, and quotes around a value are all understood, because that is what a .bashrc and a proxy helper's README look like. A line that is not an assignment is named under the box and blocks Save rather than being quietly dropped — a variable that is configured and not applied is the one failure nobody thinks to look for.
Unlike the two credential boxes beside it, this one is prefilled with what is stored: it is configuration a person edits in place, and a write-only box would mean retyping the lot to change one line. It travels only over this plugin's own route, which is fenced exactly as /api is. The log names the variables — never their values — each time a server connects, which is where you confirm that what you typed is reaching the machine.
Node ignores http_proxy, and that is the whole trap
curl reads it. wget reads it. npm, pip, apt and git read it. Node does not. Its fetch goes straight past the variable and connects directly — so on a machine whose only route out is a proxy, setting one correctly gets you a setup that downloads fine and a harness whose every model request fails, with DeepSeek API request to https://api.deepseek.com failed and a perfectly good proxy sitting in the settings beside it. The harness talks to the model over bare global fetch with no dispatcher to hook, so there is nothing else to configure.
Node's opt-in for it is NODE_USE_ENV_PROXY=1. Setting http_proxy, https_proxy or all_proxy here adds it for you, and adds it visibly — the connection's log line names it alongside the rest, so it reads as something that happened rather than as magic. An explicit value is never overwritten, which is how you say "proxy the shell, not Node": NODE_USE_ENV_PROXY=0.
That switch only exists from Node v22.21.0 and v24.0.0 (nodejs/node#57165); v23 never got it. Two consequences, both handled:
- The vendored Node is v22.23.2. It was
v22.20.0— one patch release under the line, which is exactly how long a correct proxy took to look broken. - On a server with a proxy configured, a system Node older than v22.21.0 is replaced rather than reused, and the log says why. Reuse is still the default everywhere else; a proxy is only a proxy if the runtime will use it.
A server set up before this needs one press of Reinstall to get the newer Node and a launcher pointing at it.
One more thing the opt-in changes: a proxy value with no scheme (127.0.0.1:7890, which is how most people write one) makes Node throw ERR_PROXY_INVALID_CONFIG during startup — the harness does not fail to use the proxy, it fails to run. The form refuses to save one, so that never reaches a server.
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, browsing its filesystem for the folder picker, attaching and detaching a folder, entering and leaving a server, 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.
The remote window's own traffic does not ride this route. It rides a loopback listener of its own, on an OS-assigned port, because what goes down it is another dsh's whole web surface — absolute asset paths, keep-alive, two WebSocket upgrades — and a page served under a prefix would be a page asking for files nobody serves.
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 carryaria-selectedinstead; 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.
The Plugin hub card replaces that form and groups the rest by the job they actually do, rather than presenting twelve fields as equals:
- Servers — the list, and the door into the Remote Connect window. Servers are still only written by that window.
- Install on a server —
allowUpload; the version pins (dshVersion,nodeVersion,pnpmVersion) wait behind Advanced. - Code mode —
codeBundle; the profile and omdsh-tui source paths wait behind Advanced. - Remote window —
windowIdleMinutes;webProfilewaits behind Advanced. - Conversations —
syncIntervalSeconds. - Agent credentials —
forwardEnv.
Without the hub, the generic form still draws these fields, in the same order as the groups above. The fields themselves:
| Field | Default | What it decides |
|---|---|---|
allowUpload |
true |
whether a server with no internet may be set up by uploading from here |
dshVersion |
latest |
which harness release to install on a server |
nodeVersion |
v22.23.2 |
which Node to install on a server whose own is older than 22, or older than v22.21.0 with a proxy configured |
pnpmVersion |
11.7.0 |
which pnpm to install on a server that has none; the harness installs profile plugins with it |
codeBundle |
@omdsh-plugins/omdsh-tui-app |
the terminal application that profile composes; space-separated for an application that ships as more than one package |
codeProfile |
dsh-code |
the profile a Code terminal boots on a server; the terminal application is installed into it |
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 |
tuiLocalPath |
(empty) | an omdsh-tui checkout on this machine to build and upload instead of downloading the source; setting it makes every terminal install take the upload route |
windowIdleMinutes |
30 |
how long a remote window nobody is connected to is kept before the harness there is shut down; 0 keeps it |
webProfile |
web |
the profile a server boots to serve the remote window; the harness's own shipped template, created on first use |
syncIntervalSeconds |
60 |
how often conversations are copied down; 0 switches the background copy off |
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
npx @omdsh-plugins/omdsh-plughub add omdsh-remdev
That is the plugin hub's
installer with argv where the button was. It resolves this plugin from the
collection's registry, installs it
from its GitHub repository, and writes the pnpm build-allowlist entry a bare
dsh plugin add github:… would leave to you — the entry carries the commit pnpm
resolved, so it can be copied out of a failure and never written down in
advance.
dsh plugin --profile web add @omdsh-plugins/omdsh-remdev is not that command yet:
this package is not on npm, and pnpm answers ERR_PNPM_FETCH_404. The same
install is also a button, on this plugin's card in Settings → Plugins → Plugin
hub, once the hub itself is in the profile.
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-codemode 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-codemode 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 remote window is a frame, not an integration. What fills it is the server's own dsh, so this app's sidebar, mode switch and side panels are not over it, and the plugins in that server's
webprofile decide what its own switch offers — a plain harness GUI unless you have installed the omdsh plugins there too. Coming back out is the strip at the top. - The header button needs the header. While a conversation is still blank the harness clears its header for the hero screen, so the button is not there either — a brand-new remote conversation is entered from the Remote development window instead. It also needs
conversation.session.header.utilitiesto exist: the registration ridesslots.inject, so a profile whose conversation UI does not declare that seat loses this one shortcut and nothing else. - One window per server, not per folder. A remote harness serves every directory on the machine it runs on, so entering a second mounted folder on the same server is entering the same window; which project you work in is a choice you make inside it.
- A remote window is reachable by every process on this machine while it is open. Its loopback port is the harness's own fence — the same access a local process already has to the local dsh — but it is a door onto somebody else's machine, and it exists for as long as the window does.
- Conversations you hold in a remote window reach this app's sidebar only on the next sync. The remote harness writes them; the mirror copies them down at
syncIntervalSeconds, so a turn taken over there appears here a minute later rather than as it happens. - 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/skillsis 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 be given a terminal gets no Code mode. Files, terminals and conversation sync all still work — the failure is logged and provisioning finishes — but opening a remote Code terminal on it is refused with the reason until an install somebody presses succeeds.
- Installing omdsh-tui needs pnpm in one of the two places. On the server it is the vendored
tools/bin/pnpmshim the provisioning run arranges; for the local-upload fallback it is this machine's ownpnpm, or a pnpm fetched throughnpx, and a machine with neither reports that instead of guessing. - A profile row naming an unreachable package poisons every later install, so it is cleared first.
dsh plugin add <path>forwards topnpm add <path>, and pnpm resolves the project's existing direct dependencies before applying the mutation. A profile still carrying"@omdsh-plugins/omdsh-tui-app": "^0.1.0"from an earlier attempt therefore fails the whole command on a 404 for a scope npm has never carried — and thelink:that would have replaced the bad row never gets written, so Reinstall trips on it forever. Any row for the two omdsh-tui packages that is not alink:orfile:is dropped before the add; anything else in the profile is left alone, which is the same rule that protects@deepseek-ai/dsh-base. - The omdsh-tui install is a whole build, not a file copy. The dependency install and the
tsdownbuild run on whichever side fetches the source, so the first setup of a server takes minutes and pressing Install again takes the same minutes — which is what installing from source means. It is why an automatic run installs only what is missing, and why the update is a thing you ask for. - The server has to be POSIX. Every remote path is built with
node:path/posixand 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.
还没有评论,来写第一条。