DeepSeek Harness 插件

omdsh-remdev

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

跳到安装方式

来源信息

GitHub 仓库
omdsh-plugins/omdsh-remdev
最近更新
2026年8月20日
分类
界面增强
GitHub stars
0
载体类型
plugin
目录证据
上游声明已找到 dsh.bundle
证据路径
package.json#dsh.bundle
核对版本
0.1.0-rc.8
上游核对日期
2026-08-20

该证据由上游目录提供。本站没有安装、运行或安全审核这个插件。

安装

默认先复制一段 Prompt,让 Agent 读 GitHub 仓库和源码;需要自己装时再切到命令。

复制这段 Prompt,发给 DSH、Codex 或其他 Agent,让它先读 GitHub 仓库和源码。

请先不要安装或执行任何命令。阅读这个插件的 GitHub 仓库、README 和关键源码,然后用清楚、直接的方式回答以下问题,帮助我判断它是否适合我的需求:

1. 这个插件是什么,解决什么问题;
2. 适合哪些用户和典型使用场景;
3. 安装后如何使用,并给出一个最小使用示例;
4. 有哪些已知限制,以及隐私、安全、兼容性或维护风险;
5. 给出“推荐 / 有条件推荐 / 不推荐”的明确建议和理由。

请区分仓库明确说明、根据源码推断和未知信息。证据不足时请明确说明,不要猜测或照抄 README。

GitHub:https://github.com/omdsh-plugins/omdsh-remdev
插件名:omdsh-remdev
作者:omdsh-plugins

检查来源文件

安装前先看这个插件目录里的 README 和其他文件。

文件资源管理器4 个文件
README.zh.md来源说明 · 只读预览
README 语言

omdsh-remdev

English | 中文

DeepSeek Harness 的 Web 界面提供远程开发:把工作区挂到一台 SSH 服务器上,它的文件、终端、会话和智能体就全在那台机器上跑——Work 模式也一样,因为 Work 模式就是那台机器上的 harness,只是显示在这里。

它提供什么

界面从哪来
侧栏 Workspaces 栏最右边的 远程连接 按钮一个挂在 shell.overlay 上的条目:在自己的座位上什么都不画,portal 到 [data-slot="sidebar.workspaces"]
远程开发窗口——服务器、安装、目录选择、实时日志src/client/RemoteDialog.tsx,走 /omdsh-remdev 前缀路由,围栏和 /api 是同一份 trusted hosts
插件中心卡片——服务器列表,以及按「安装 / Code / 远程窗口 / 会话 / 凭据」分组的可配项挂在 omdsh.plugin.card 上的一张卡片,id 用包名;代替通用表单,因为通用表单画不出服务器列表,还会把十二个旋钮画成彼此平等
在服务器上——挂载项目的会话头上的一颗按钮,那也正是 Work 模式唯一可能悄悄变成本地的地方挂在 conversation.session.header.utilities 上的一个条目,那是 ui-conversation 自己的工具行(src/client/RemoteEnter.tsx
远程窗口——整个界面交给一台服务器,Work 和 Code 都跑在它上面用远程启动器起 dsh --profile web,隧道走已经开着的那条 SSH 连接,本机开一个 loopback 监听(src/remote-web.tssrc/web-proxy.tssrc/client/RemoteWindow.tsx
每一行远程工作区上的地球角标portal 进 [role="treeitem"][aria-expanded],位置来自测量
remdev 服务——remoteFor(cwd)ctx.reflect.provide,也是 omdsh-sidepanelomdsh-codemode 在读文件或起进程之前问的唯一一个问题
服务器上项目的技能(.dsh/skills.agents/skillssrc/remote-skills.ts:一个注册在 harness 自己的 skills 服务上的 provider,通过 SFTP 把挂载目录里的 SKILL.md 读回来
一个用起来和普通工作区一样的远程工作区$DSH_HOME/remotes/<服务器>/<项目> 下建一个真实的镜像目录——这正是 workspaces.create 一直在要的东西
服务器上的会话,在本地列出、点开继续会话镜像:把远端日志拷下来,只改 header 里的一个字段
omdsh-remdev 设置命名空间ctx.inject(['settings']),服务器和它们的凭据放在隐藏且 role('secret') 的 store 里
remdev.connect 快捷键命令组合里有快捷键层时注册到 ctx.shortcut;默认键位 CmdOrCtrl+Shift+C(浏览器标签页里是 ⌥⌘C),由 omdsh-shortcuts 的文档决定——见 src/client/shortcut.ts

侧栏 Workspaces 栏最右边多一个 远程连接 按钮,点开就是一个窗口:选一台机器、连接(机器上没有 harness 就自动装上)、打开文件夹。打开之后得到的是一个普通工作区,只是文件夹图标上多了个地球——行、会话、Code 模式都和本地项目一模一样,只不过跑在别人的电脑上。键盘上也有同一个入口:组合了 omdsh-shortcuts 之后,⌘⇧C(浏览器标签页里是 ⌥⌘C)打开这个窗口,按钮的 tooltip 会显示当前键位。

本地开发什么都没变。没装这个插件的组合解析不到服务,普通本地目录也解析不到挂载,两种情况都走原来那条分支。

远程工作区凭什么是普通工作区

workspaces.create 会用 realpath 规范化传进去的路径,并且要求那儿真的有个目录。这不是需要绕开的障碍——这是 harness 在说"工作区是本机上的一个位置"——所以这个插件就给它一个:在 $DSH_HOME/remotes/<服务器>/<项目> 下建一个真实的空镜像目录,里面只放一份自我说明的 README.txt,给哪天翻到它的人看。

于是下游全都原样可用。会话库按这个目录分组,注册表按它记账,projection 按它做 key,用量统计按它汇总。唯一需要知道"这目录是个替身"的代码,就是那些正要在里面读文件或起进程的代码,而它们只需要问一个问题:

const remote = ctx.get('remdev')?.remoteFor(cwd)
const listing = remote === undefined
  ? await listLevel(cwd, path)      // 和原来完全一样
  : await remote.list(path)         // 同一份列表,走 SSH

接缝就这么多。omdsh-sidepanelomdsh-codemode 各自只多了一份结构化的服务视图和一个分支;两者都不从这个包 import 任何值,也都不知道 SSH 是什么。

项目里的技能

harness 的技能系统按会话的 cwd 找技能:它自己的 filesystem provider 走本地文件服务,读 <项目>/.dsh/skills<项目>/.agents/skills。远程工作区的 cwd 是镜像目录,一个空的本地替身,所以服务器上项目的技能,本机的 agent 根本看不见。这个插件在 harness 的 skills 服务上注册了一个 provider(remdev),填的就是这个洞:cwd 解析到某个挂载时,它把服务器上这两个目录里的 SKILL.md 通过 SFTP 读回来,放进同一个技能目录。目录式技能和扁平 .md 技能都支持;名字、描述、whenToUse、invocation 策略和 metadata 都按 harness 自己的语法解析。

有四件事值得写下来:

  • 只读挂载内的两个固定路径。 护栏和文件浏览器是同一份,技能读不到挂载目录之外的任何东西;技能文件读不出来就跳过、记一行日志,其余技能照常。
  • 技能的 resourceBase 是 opaque 而不是 directory。 技能引用的模板和脚本在服务器上,本地文件工具读不到,所以模型拿到的提示写的是"在服务器的哪个目录、怎么到那儿",而不是一个 read 会失败的路径。要用这些资源,走远程文件面板或远程终端;Code 模式的 agent 本来就在服务器上跑,在那边本地读同一份 .dsh/skills,跟这里无关。
  • 不监听远端变化。 技能目录随注册表的缓存一起失效:挂载表一变(增删挂载、改服务器)就重读。服务器上刚加的技能要等下一次刷新才出现在目录里;会话中途在服务器上改技能文件,也一样。
  • 服务器连不上时这个 provider 就不提供技能,harness 会把这次观察标成"不完整"、下次再试,本机 ~/.dsh/skills 里的用户技能不受影响。

.dsh-server

目录结构照搬 .vscode-server,理由和 VS Code 当初选它一样:远程安装得扛得住四种服务器——自带 Node 的、自带版本不对的、根本没有的、装了好几个的。

<dshHome>/.dsh-server/
  bin/dsh              每个终端真正执行的启动器
  node/<version>/      私有 Node,只有系统那个用不了时才有
  tools/pnpm/<v>/      私有 pnpm,只有系统那个用不了时才有
  tools/bin/           上面这些的 shim,挂在 launcher 的 PATH 上
  cli/<version>/       npm prefix,放 @deepseek-ai/dsh 及其依赖图
  data/                远端 harness 的 DSH_HOME:profile、会话、storages
  install.log          最近一次安装过程,留着回答"当时到底怎么了"

bin/dsh 就是这个插件和其余部分的全部约定。终端只跑这个路径,所以"选了哪个 Node""装了哪个版本"只在一处决定,别处不再重复。

两条安装路径。 先让服务器自己下载 Node、自己装包;不行就本机把两样都准备好,经 SFTP 推上去。有这个兜底是因为大家最想用这功能的服务器——内网集群节点、跳板机后面的机器——恰好就是连不上 nodejs.org 的那些。这条路要传 ~100MB,所以永远是第二选择。

但第一条路得真的能"失败"。 下载失败不难办:兜底立刻接管,整件事几秒钟就过去了。真正的麻烦是下载卡住——防火墙不拒绝、只丢包的机器就是这样,到服务器的连接一切正常,keepalive 什么也看不出来。而 curl 默认要等 300 秒才判定连不上,传输中途则无限期地等;wget 两头都无限期地等,还要重试二十次。所以每个下载都带上 --connect-timeout--max-time(wget 是 --timeout--tries=1),每条会碰网络的命令,在传输层还有一个兜底计时器压在这些参数后面。没有这些,兜底路径在它本来要救的那类服务器上根本走不到。

人也可以直接说。 连接按钮旁边勾上"从本机打包上传",就跳过服务器端的所有下载,全部在本机准备好推上去。装出来的东西一模一样,只是先试哪条路不同;有这个开关,是因为为了证明一件自己早就知道的事而白等四步超时,本身就是一种失败。tuiLocalPath 更进一步:它指定本机上的一份 omdsh-tui 源码来构建并推送,这是两头都没网时唯一可行的办法。

pnpm 也要一起带上,这条值得写下来。 dsh plugin 只是个转发器:它按名字从 PATH 上找 pnpm,没有任何办法指到别处。而 Node 官方 tarball 里只有 nodenpmnpxcorepack——没有 pnpm。于是那些需要本插件自带一份 Node 的服务器,恰恰就是之后装不上终端程序的服务器:dsh: pnpm not found on PATH、exit 127、Code profile 里只剩一个 dsh-base。所以 pnpm 也按和 Node 一样的两条路装到 tools/pnpm/<version>,并在 tools/bin/pnpm 放一个 shim,由 launcher 挂到 PATH 上。

装法是解包,不是 npm install -g,这两半都有讲究。pnpm 发布时不带任何依赖,整个运行时都在 dist/ 里,所以解开 tarball 本身就等于装好了。这样就不用在小小的服务器上再跑一遍 npm 的依赖解析——那东西能吃掉 ~500MB 的堆,一台 1GB 内存的机器已经被人眼睁睁看着 OOM 死过一次。另外它落在 .dsh-server 里而不是全局前缀:用系统 Node 跑 npm i -g 会写进 /usr/lib/node_modules,那是 root 才给得起、谁也收不回的东西。删掉 .dsh-server 依然等于删掉全部。服务器自己带了 pnpm 9 或更新的,就直接用它,什么都不下载。

shim 里用绝对路径 exec 选定的那个 Node,而不是让 #!/usr/bin/env node 自己去找——否则自带的这份 pnpm 会跑在那个"因为太旧才要自带"的系统 Node 上。

所有探测都走 login shell——而且得是一个能起来的。 ssh host command 跑的是非交互非登录 shell,在真实的开发服务器上,这跟人自己登进去看到的 PATH 是两回事——nvm、conda、homebrew 以及各种 module load 都把工具链放在 login profile 里。实测(普通 sshd):sh -c 找不到 nodesh -lc 找得到。少了这个 -l,一台装着好好的 Node 22 的服务器会被判成"没有 Node",然后被再装上一份。

真正咬人的是"走哪个 login shell"。/etc/profile 会无条件 source 掉每一个 /etc/profile.d/*.sh,而这些文件都是照着 bash 写、也只在 bash 里试过的;偏偏 Debian 系的 /bin/sh 是 dash,一句 function foo() {} 在那儿就是语法错误——按 POSIX,非交互 shell 遇到语法错误必须退出。不是警告,也不是跳过这个文件:sh -lc 在 source profile 的过程中就以 2 退出,我们发过去的脚本一行都没跑。所以改成在服务器上现选:让每个候选 shell 先起一个 login 会话、什么都不输出,能过的才用——$SHELL(限 POSIX 系)、bashsh,最后是完全不 login 的 sh -c。整条命令里 payload 只出现一次、也只跑一次,所以 npm install 不会被兜底逻辑跑第二遍。PATH 退化,也远胜过连探测都做不了。

探测脚本会先打一行 marker,解析时只认最后一个 marker 之后的字段——因为 login shell 会执行机器上的 profile,而 profile 是会说话的:conda 的横幅要是被当成服务器的 home 目录,整个安装就装到横幅上去了。

Code 模式需要一个终端程序,而这不该是谁去按一下才有的东西。 harness 自带三个 profile bundle,没有一个是交互式终端,所以安装阶段还会建一个 profile(默认叫 dsh-code),用 @deepseek-ai/dsh-base 加上 codeBundle 指定的包组合出来——不特意改的话,那就是 @omdsh-plugins/omdsh-tui-app——并且把它装上。终端没有单独的按钮,也不再住在单独的 profile 里:一台装好的服务器要么上面有终端,要么有一条"为什么没有"的理由。

走哪条安装路径,看这个包从哪儿来。 registry 里有的终端,就用 dsh plugin add 照常装;命令通过 launcher 发出去,所以它落在正确的 DSH_HOME、正确的 Node,以及放着 pnpm shim 的那条 PATH 上。默认那个哪儿都没有:omdsh-tui 什么都不发布,没有 npm 包,没有 release,没有 tag。所以只能按它自己 README 写的方式装,也就是 checkout 安装:拉默认分支的最新提交、装依赖、构建,把两个包(@omdsh-plugins/omdsh-tui@omdsh-plugins/omdsh-tui-app)链进 Code profile,然后验证——--dump-config 必须能组合出 bundle 的层,profile 目录还必须解析得了 loader 行里点名的每一个模块。正是这道检查,拦得住"dump 完美、启动必挂"的树。

两条路和别的东西一样:有网的服务器自己拉源码、自己构建;没网的就由本机把源码下下来、装好依赖、构建好,再把成品树走 SFTP 推上去。构建用 pnpm 的 hoisted 链接模式,所以上传的树里是真实文件,不是指向本机 store 的符号链接。checkout 放在 .dsh-server/tui/ 下,删掉 .dsh-server 就一并删掉。

"从本机打包上传"挪的是构建的位置,不是源码的来源。 源码仍然来自 GitHub——只是改成本机去拉而不是服务器去拉——所以勾上它并不等于整个过程离线。而那个 URL 恰好是未认证客户端能向 GitHub 要的东西里限流最狠的:一个按需生成、ref 不固定的 tarball。几分钟里多按几次重新安装,就足够吃到一个 HTTP 429

以前这会直接终结整次安装,而暂存目录里明明就躺着一份好好的源码。现在凡是"待会儿再来"性质的拒绝——429、任何 5xx、连接断开——都会等一等再试,最多三次,服务器给了 Retry-After 就照做,并且设了上限,免得对方状态不好就把安装挂在那儿一小时。如果最终还是拿不到,就改用上一次暂存下来的 tarball,并在日志里写明它是哪天下载的:昨天的终端也远比没有 Code 模式强得多,而且没人应该去猜自己看的是哪一份源码。它永远不会被优先选用——重新安装的意思就是"拿最新的",这条路只在拿不到最新的时候才走。

tuiLocalPath 是彻底摆脱这个下载的办法:把它指向本机上的一份 checkout,就什么都不用从网上取了。

缺终端这件事,由需要终端的那一方发现。 开一个远端 Code 终端之前会先问 profile 组合了什么——一次 cat 它的 manifest,每台服务器每个进程只问一次——如果里面只有 dsh-base,那就先把终端装上,而不是去启动一个必挂的终端。补的是终端,不是整台服务器:安装流程一上来就会解析 dshVersion,要是拿完整安装来补这个缺,开一次 Code 栏就等于顺手把那台机器的 harness 升了级。这道检查看的是 bundle :一个"目录建好了、安装失败了"的 profile 从外面看跟装完的一模一样,把它当成装完的,恰恰会让最需要安装的那台机器成为永远装不上的那台。

代价是有意收着的。 自动跑的那次只装缺的部分,所以已经有终端的服务器不会重新构建一遍。失败过一次就记着失败过:下一个终端直接带着原因被拒绝,而不是再花几分钟失败一遍。而且这个失败不会拖垮别的:文件、shell、会话镜像都不需要它,所以安装流程照常收尾,并把原因报出来。

在一台就绪的服务器上按「重新安装」,就是更新。 那是唯一一次有人主动要的运行,所以不管有没有装过,它都会重装一遍终端:checkout 整树换成分支最新头、profile 重新链接、验证重做一遍——registry 来的终端则是重新从你的 registry 解析一次。这就是为什么一台看起来好好的服务器仍然值得按一下,也是为什么按下去要等几分钟。

Work 模式跑在服务器上

这个插件其他所有东西,做的都是让本地的 harness 伸手去够一台远程机器:文件走 SFTP,终端走 pty,项目技能两样都走。Work 模式做不到这一点,而这个插件曾经装作能做到。

一个 Work 会话就是 harness 的智能体循环——它的 read、它的 edit、它的 bash、它的会话日志。在挂载工作区上,这个循环跑在本机、跑在镜像目录里,而镜像目录是一个空的本地替身:glob 只找得到一个 README.txtbash 跑在笔记本上,改一个文件改到服务器压根没听说过的地方。Code 模式从第一天起就是远程的(它起的是远程启动器),于是同一个工作区的两半对"自己在哪台机器上"给出了两个答案。

所以,为挂载项目提供 Work 的那个 harness,就是那台机器上的 harness。 有两个入口,意思完全一样:挂载项目的会话头上那颗 在服务器上(问题就是在那儿冒出来的),以及远程开发窗口里那台机器一行上的 进入服务器。两个都会把屏幕交给它:dsh --profile web 跑在那边,只绑那台机器自己的 loopback;隧道走已经开着的那条 SSH 连接;本机的 loopback 监听才是浏览器指向的地方。填满整个画面的是服务器自己的 dsh——它的会话、它的 Work 列、它的文件、它的智能体、它的终端——顶上那条窄条是屏幕上唯一还属于本地的东西:你在哪台机器上,以及怎么回去。

这就是 VS Code 的做法,理由也和 VS Code 一样:干活在代码所在的地方干,界面在人所在的地方显示。

服务器为此什么都不用装。 web 是 harness 自带的 profile 模板之一(dsh-basedsh-web-app,两个都随安装自带),而启动一个还不存在的 profile 会用那个模板把它建出来。所以被这个插件装过的服务器已经能提供 Web 界面了:没有 bundle 要加,没有包要下,没有 registry 要能连上。它是唯一一个不需要任何安装步骤的远程界面——这也是为什么"进入服务器"不要求 Code 模式需要的那个终端程序,在 omdsh-tui 构建失败的机器上照样能用。

端口是读出来的,不是挑出来的。 --port 0 让操作系统挑,harness 在 Loader 稳定之后会打印 dsh web: http://127.0.0.1:<端口>——它是在 /api 那个路由的持有者挂载完之后才打印的,所以这行既是地址也是"就绪"的信号。指定固定端口会撞上那台机器上跑的别的东西;解析这行字一次拿到两个事实。跑启动器用的是 login shell,理由和这里其他每条远程命令一样;解析会忽略其他所有行,因为登录 profile 是会说话的,MOTD 里带的 URL 不是答案。

中途什么都不改写,这是一条安全性质,不是省事。 harness 用 Host 头给 /api 设围栏——loopback,或者声明过的 trustedHosts——所以代理到一个公网名字上的话,必须改写 HostOrigin 才过得去。这个监听绑的是 127.0.0.1,所以浏览器填进去的 authority 在对面本来就是一个 loopback authority,而页面自己发的请求相对它就是同源的。拿真的 dsh --profile web 量过:workspace.listworkspace.create 通过隧道、在另一个 loopback 端口上都答 ok,两条 WebSocket 下行也都升级成功。所以这个代理转发的是 TCP,什么都不解析。

它确实带来的那点暴露,值得直说:窗口开着的时候,这台机器上任何进程都能通过那个端口够到那台服务器的 harness——和任何本地进程本来就能够到 127.0.0.1:3080 上本地 harness 的程度一样。用的是 harness 自己那道围栏,不是更弱的一道;那个端口从网络上永远够不到;而且它只在有人正在那台服务器上干活的时候存在。

按下去进不去的时候,它会把你送到能看到原因的地方。 会话头上那颗按钮只放得下一个图标加三个字,所以它不打算在那儿报错:它会把远程开发窗口开到那台服务器上,那里已经躺着这次拒绝的原因和整份安装日志。窗口里那颗"进入服务器"是在表单里,有地方写一句话,所以它就地报错。一个动作,两种解释自己的方式,而且共用同一个"正在启动"的标记——所以按了一边再去看另一边,看到的是"正在进行",而不是一颗好像什么都没干的按钮。

你在这里挂上的文件夹,那边也列得出来。 一台 harness 从没跑过的服务器,工作区列表是空的,进去之后会落在一个"选个目录"的界面上——而那些目录你早就选过了。所以每个挂载都会在远端用它自己的 workspace.create 注册一遍(就是这个应用挂载文件夹时发的那个调用),而且走的正是浏览器接下来要用的那扇门,于是隧道坏了会变成一句话,而不是一个空白框。被拒绝只值日志里一行,别的没有;窗口两种情况下都能用。

它能结束的每一种方式,结束得都一样。 远程 harness 跑在的那条 channel 就是它的生命周期:关掉 channel 就是给那个进程发挂断。所以退出窗口、删掉服务器、连接断开、插件卸载、空闲清扫,最后都到同一个地方,没有哪一条能在没人用的机器上留下一个还在跑的 harness。没有任何连接的窗口会在 windowIdleMinutes(默认 30 分钟)之后关掉,这个判断要两个条件同时成立——开着的页面握着两条 WebSocket 下行,所以只看"没有连接"会把正在被人读的窗口给收掉。如果那个 harness 因为它自己的原因停了,画面跟着它走,而不是停在一扇通向虚无的门上:浏览器渲染的状态来自 host,而注意到这件事的正是 host。

起不来的时候,它说了什么就报什么;一个字都不说的时候,会给出判断。 profile 起不来、启动器不在、参数不对,这些都会自己出声,报错报的就是那些话。而"非零退出但一个字节都没打印"是另一码事,而且是真会发生的一码事:在一个平台上打包、推到另一个平台的 .dsh-server,里面的原生模块是给错的机器编的,第一次原生调用就 SIGBUS 死掉,什么都不输出。所以静默退出会报出退出码和造成这种现象的原因,并指向 重新安装——那才是这种服务器上真正有的那颗按钮。平台错配的那棵树是装成功了的:启动器答得出话,探测报得出版本,这台服务器的状态就是 ready,而 ready 状态下唯一的安装控件就是"重新安装"。人按下去的那一次运行,会把为错误平台解析出来的那棵树换掉,而不是回一句"已经装好了"。要把话说得严格准确,还有一条:从本机打包上传的替换,只有在上传能把 npm 对准那台服务器时才是对的,所以服务器能连 registry 的话,按之前先把"从本机打包上传"的勾去掉。

会话回到本地

服务器上的会话是那边的 harness 写的。这个插件把日志拉回来,于是本地的列表、搜索、标题、用量统计全都能用上,而做这些事的代码一行都不用改。

拷贝时只改一行的一个字段:header 里的工作目录,在本地必须指向镜像目录而不是远端路径——工作区注册表记账时会拿这个 cwdrealpath,并要求那儿真的有目录。其余部分逐字节不变,会话 id 也不变,这正是那一行可以点开的原因:点开就用 --session-id <那个 id> 跑远端启动器,远端 harness 接着它本来就有的会话继续。

有两个细节至关重要,而且都是跑起来才发现的,不是读代码读出来的:

  • 文件写到本地 backend 认为这个 header 该去的位置(sessionPersistence.locate),并且用本地 backend 自己的编码。往配置成 zstd 的库里丢一个纯 .jsonl 不会优雅降级:backend 会在 list 里抛编码不匹配,倒掉的是整个会话列表,而不是那一个会话。
  • Zstandard 会话日志的第一帧必须解出恰好一行。把整份日志写成一帧不是弄坏一个会话——list 抛错、工作区注册表初始化失败、整个应用起不来。

镜像是单向的,也不是合并。远端是唯一的写入者;本地那份每次变化都整份替换,所以这里不提供任何编辑入口。

日志

每台服务器一份,而且存在宿主进程里,不在浏览器里。

这不是实现细节,而是"能用"和"不能用"的区别。日志放在页面里的话:刷新一次没了、另开一个窗口是空的、开对话框之前发生的事全看不到,而且所有行进同一个列表——两台服务器的输出混在一起。宿主进程才是真正在干活的那一方,不管有没有人看着它都在干,而且它比任何看着它的浏览器都活得久。所以任何时刻打开对话框,问一句"发生了什么"都能得到答案,包括十分钟前在一个已经关掉的窗口里启动、现在还在跑的安装。

它记录的是整条生命线,不只是安装:正在连接、已连上、探测到了什么、断开以及为什么断、安装过程的每一行、挂上、断开了哪个文件夹、同步了几个会话。时间戳和级别都由宿主打,因为只有宿主知道一件事是什么时候发生的、以及有没有出错——中途才接进来的浏览器两样都不知道。一次卡在同一行 npm 输出上两分钟的安装,和一次很快的安装看起来一模一样,直到旁边的时钟说出真相。

每台服务器一个有界环形缓冲,500 行——一次完整的安装装得下,下一次会把上一次挤出去,而这正是该保留的那一份。不做持久化:日志描述的是这个进程做过什么,存下来的那份描述的是某个更早的进程做过什么,那是另一件事,而且没什么用。

凭据

三种认证方式,每台服务器单独选:运行中的 ssh-agent私钥、或者密码

私钥有两个来源,两个都留着,因为它们回答的是两种不同的处境:

  • 本机上的密钥文件——运行 dsh 那台机器上的一个路径。密钥本来就在那台机器上时用这个(本地安装的常见情况),密钥不会离开它原本所在的磁盘。开头的 ~ 会被展开,因为大家写密钥路径就是这么写的。
  • 直接粘贴密钥——密钥正文本身,和连接一起保存。当密钥不是 dsh 宿主机上的文件时,这是唯一的入口:别人给你的密钥、密码管理器里的密钥、或者用一台机器上的浏览器去驱动另一台机器上的 dsh。粘贴时丢了结尾换行也没关系——OpenSSH 写文件时会加、所有解析器也都按有换行来写,所以这里会补回去,而不是报一个"密钥格式错误"。

加密过的密钥,其口令填在密码用的那个框里。两个凭据框都是只写的:已存在的凭据到浏览器只剩 hasSecret / hasPrivateKey 两个布尔值,所以留空表示保持不变,主动清空才是删除。它们都不会离开宿主进程:本插件自己的路由只发去掉凭据的视图,settings 那条线也一样——凭据所在的 store 声明了 role('secret'),settings 服务在描述跨到浏览器之前就把它整个剥掉了。密钥文件只在拨号时读一次,在内存里只活拨号那一瞬。

改了凭据会断开当前连接、重新拨号,而不是继续用旧凭据认证出来的那条连接——连接池的 key 里放的是凭据的哈希,而不是"有没有凭据"这个标志位。

让服务器能连上模型

远端智能体是另一台机器上的一次全新登录,什么都继承不到;本地 Code 终端习以为常的模型凭据,在那边根本不存在。forwardEnv 里列的变量(默认 DEEPSEEK_API_KEYDEEPSEEK_BASE_URL)会在终端打开时从本进程读出来,export 进远端 shell。

export 而不是 env KEY=value cmd,这是安全决定不是风格决定:env 前缀会把每个值放进命令的 argv,机器上任何别的用户 ps 一下就看得到。服务器上真正存在过的 argv 只有启动器自己的——一个路径、一个 profile 名、一个会话 id,都不是秘密。

服务器上已经自己配好凭据的话,这一项完全不需要;把列表清空就什么都不发。

这台服务器自己的环境变量

每台服务器都有一个填 KEY=value 的框,和主机名、用户名在同一张表单上。凡是本应用在那台机器上跑的东西,都会先导入它们。http_proxy 就填在这里。

按服务器分别配,而不是一份列表管所有机器,因为这本来就是"这台机器在网络里的位置"这种事:同一套部署里的两台服务器经常需要不同的代理,或者其中一台压根不需要。

"凡是"两个字就是这个功能本身。 它由 SSH 传输层统一施加,而不是靠那三十来处执行点各自记得,所以它覆盖:

  • 安装过程——Node 压缩包、npm install、pnpm 压缩包、omdsh-tui 源码、profile 安装。这一条最要紧:一台只能靠代理出网的服务器,不带代理就等于完全不能出网,而兜底方案是从本机上传 100MB。
  • Work 模式的会话和 Code 模式的终端,两者都是通过启动器拉起来的远端 harness。
  • 底部面板的 shell,以及你在里面接着跑的任何东西。

它不覆盖 SFTP,SFTP 也不需要:列文件、预览文件、把会话拉回本地,都直接走 SSH 连接本身。

export 而不是 env KEY=value cmd,理由同上——代理地址里经常带密码,而 env 前缀会把它放到 ps 能看见的地方。值整体带引号送出,所以空格、$、引号都按你写的样子到达,不会被当成 shell 代码。

你填的值压得过机器自己设的值。 login shell 会 source 掉每一个 /etc/profile.d/*.sh,而里面放一个无条件 export http_proxy 的代理脚本,是真实服务器上真实存在的情况——所以这份环境会在那个 shell *内部*再施加一次,排在所有 profile 之后。终端里是同样的顺序;对上 forwardEnv 从本机复制过去的变量也是同样的顺序:那些是通用默认值,而这一份是关于某一台服务器的事实。

这个框可以直接粘贴。开头的 export# 注释、空行、值两边的引号都认得,因为 .bashrc 和代理脚本的 README 本来就长这样。是赋值的那一行会被点名列在框下面并挡住保存,而不是被悄悄丢掉——"配了但没生效"是最没人会想到去查的一类故障。

和旁边两个凭据框不同,这个框会用已保存的内容预填:它是配置,是要就地改的东西,写完就看不见的框只会逼人为了改一行而全部重打。它只走本插件自己那条路由,围栏和 /api 完全一样。每次连接时日志会列出变量名——绝不列值——那里就是你确认"填的东西确实到了机器上"的地方。

Node 根本不看 http_proxy,这才是真正的坑

curl 看。wget 看。npm、pip、apt、git 都看。Node 不看。 它的 fetch 会完全绕过这个变量直连——所以在一台只能靠代理出网的机器上,代理配对了,你得到的是:安装过程一切正常,而 harness 的每一次模型请求都失败,报 DeepSeek API request to https://api.deepseek.com failed,旁边设置里还躺着一个好好的代理。harness 走的是裸的全局 fetch,没有任何 dispatcher 钩子可以接,所以也没有别的地方可配。

Node 为此提供的开关是 NODE_USE_ENV_PROXY=1只要你填了 http_proxyhttps_proxyall_proxy,这里就会自动把它补上,而且是明着补——连接日志会把它和其它变量一起列出来,所以它是"发生过的一件事",不是魔法。你自己写过值就不会被覆盖——NODE_USE_ENV_PROXY=0,这就是"只让 shell 走代理、不让 Node 走"的写法。

这个开关从 Node v22.21.0 和 v24.0.0 才有nodejs/node#57165),v23 一直没有。由此带来两个后果,都已经处理:

  • 插件自带的 Node 是 v22.23.2。之前是 v22.20.0——正好差一个补丁版本,而一个配置完全正确的代理,就是这么"看起来坏掉"的。
  • 在配了代理的服务器上,系统 Node 低于 v22.21.0 的会被替换而不是复用,日志里会写明原因。其它情况仍然优先复用;毕竟运行时用不了的代理不算代理。

在这次改动之前装好的服务器,按一次重新安装就能拿到新版 Node 和指向它的启动器。

开关打开后还有一件事会变:没写协议头的代理地址127.0.0.1:7890,大多数人就是这么写的)会让 Node 在启动阶段ERR_PROXY_INVALID_CONFIG——不是代理没生效,是 harness 根本起不来。表单会拒绝保存这种值,所以它到不了服务器上。

它开的那条路由

一条前缀路由 /omdsh-remdev,注册在 ctx.webServer 上。窗口的每一次调用都走它:读状态、保存和删除服务器、探测、安装、为目录选择器浏览远端文件系统、挂载和断开文件夹、进入和退出服务器、读日志、要求同步——外加一条 server-sent events 流,把状态变化和新日志行推给每一个开着的窗口。

远程窗口自己的流量走这条路由。它走自己的一个 loopback 监听,端口由操作系统分配,因为流过去的是另一个 dsh 的整个 Web 界面——绝对路径的静态资源、keep-alive、两次 WebSocket 升级——而挂在前缀下面的页面会去要一堆没人提供的文件。

它的围栏和 /api 完全一样,理由也一样。 每个请求在被回答之前都要拿 harness 自己的 trustedHosts 校一遍,过不了的只有 403,别的什么都没有。一条存着 SSH 凭据、还会往别人机器上装软件的路由,可达性必须正好等于 harness 自己的控制面,一分不多——而且这道围栏用的是 harness 那份名单,不是这里另写一份,所以部署方把 trustedHosts 收紧,这条路由跟着一起收紧。

除了读状态之外全是 POST,连读日志也是。一个能装软件的 GET 就是一个能被链接触发的 GET;而一份点名了谁家服务器的日志,不该留在 URL 里。

跨过这条路由的是一份去掉凭据的视图:浏览器拿到的是 hasSecrethasPrivateKey,两者的正文一个字节都拿不到。

界面从哪儿来

harness 在侧栏工作区那一片没有声明任何 slot——整片是 ui-workspace 填的一个 single slot,里面的一切都是那个包的私有组合。所以两个界面都挂在 shell.overlay 上,在自己的座位上什么都不画,然后 portal 到自己找到的锚点:

  • [data-slot="sidebar.workspaces"]——slot 渲染器公开的约定。它唯一的元素子节点是那片区域的根,根的第一个子节点是 Workspaces 栏,且用高度校验而不是想当然。
  • [role="treeitem"][aria-expanded]——工作区行。会话行也是 treeitem,但带的是 aria-selected;在会话行上读标签读到的是时间戳。

类名根本不能依赖,就算想依赖也不行:那个包里每个类名都是哈希过的 CSS module 名。

角标 portal 进的是,不是文件夹图标,位置来自测量。行在 :hover 时会把文件夹换成展开箭头——所以住在文件夹里的角标会在鼠标移上去时消失,而那正是要看它 tooltip 的时刻。

锚点找不到就什么都不画。 不退而求其次,不在某个角落飘一个按钮。出现在错位置的按钮比不出现更糟:后者只是一个够不着的功能,前者是一个把周围应用弄坏的功能。

配置

一个 settings namespace:omdsh-remdev。服务器和挂载放在隐藏的 store 里,由本插件自己的对话框写——那是一个通用表单拒绝绘制的对象列表,里面还有任何表单都不该渲染的凭据。它除了 hidden 之外还声明了 role('secret'),两者干的是两件事:hidden 让它不进表单,role 让它不上线。

插件中心里这张卡片代替通用表单,按实际在干什么分组,而不是把十二个字段画成彼此平等:

  • 服务器——列表,以及打开远程连接窗口的入口。服务器仍然只由那个窗口写入。
  • 在服务器上安装——allowUpload;版本号(dshVersionnodeVersionpnpmVersion)收在高级选项里。
  • Code 模式——codeBundle;profile 和 omdsh-tui 源码路径收在高级选项里。
  • 远程窗口——windowIdleMinuteswebProfile 收在高级选项里。
  • 会话同步——syncIntervalSeconds
  • 智能体凭据——forwardEnv

卸掉插件中心之后,通用表单仍能画出这些字段,顺序与上面几组一致。字段本身:

字段默认值决定什么
allowUploadtrue不能出网的服务器是否允许用本机上传的方式装好
dshVersionlatest在服务器上装哪个版本的 harness
nodeVersionv22.23.2服务器自带 Node 低于 22(配了代理时是低于 v22.21.0)时给它装哪个版本
pnpmVersion11.7.0服务器上没有 pnpm 时给它装哪个版本;harness 用它装 profile 插件
codeBundle@omdsh-plugins/omdsh-tui-app那个 profile 组合的终端程序;程序分成多个包时用空格分隔
codeProfiledsh-code服务器上 Code 终端启动的 profile 名;终端程序也装进这个 profile
tuiRepohttps://github.com/omdsh-plugins/omdsh-tui.git安装 omdsh-tui 终端所用的 git 仓库;服务器到不了 github.com 就指向镜像
tuiLocalPath(空)本机上的 omdsh-tui 源码目录,填了就用它构建并上传,不再下载源码;填了之后终端安装一律走上传这条路
windowIdleMinutes30没有任何连接的远程窗口保留多久,之后就把服务器上的 harness 关掉;0 表示一直留着
webProfileweb服务器上用来提供远程窗口的 profile 名;这是 harness 自带的模板,第一次用到时自动创建
syncIntervalSeconds60多久把会话拉回来一次;0 关闭后台同步
forwardEnvDEEPSEEK_API_KEYDEEPSEEK_BASE_URL从本机复制到远端智能体的环境变量

每个字段都是 applies: 'live':改动一提交就被采纳,下一次拨号、安装、开终端、同步用的就是新值,没有一项要重启。几个版本号是在"装一台服务器"的时候读的,所以改了它只影响之后装的服务器,不会去动已经装好的那些。

安装

npx @omdsh-plugins/omdsh-plughub add omdsh-remdev

这就是插件中心的安装器,只是入 口从按钮换成了 argv。它从这套集合的 registry 里解析出这个插件、从它的 GitHub 仓库装上,并把那条 pnpm 构建白名单写好——裸的 dsh plugin add github:… 会把这一步留给你,而那条记录里带着 pnpm 解析出来的 commit,只能从报错里抄,事先 写不出来。

dsh plugin --profile web add @omdsh-plugins/omdsh-remdev 现在还不是那条命令:这个 包不在 npm 上,pnpm 会回 ERR_PNPM_FETCH_404。这一次安装也可以是一个按钮——只要 profile 里已经有插件中心,按钮就在设置 → 插件 → 插件中心里这个插件的卡片上。

或者从 checkout 装:

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

卸载是同一条路:

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

卸载会把按钮、窗口、角标、服务和路由一起带走。$DSH_HOME/remotes 下的镜像目录留在原地,已经拷回来的会话也留在原地——它们本来就是普通目录和普通会话,而这正是整个设计想要的。

两个方向上都不构成硬依赖。 装上之后 omdsh-sidepanelomdsh-codemode 会自动获得各自的远程分支,两者都不强制要求它存在——它们走的都是规则 9 里说的懒读:真要解析一个目录时才 ctx.get('remdev'),拿回 undefined 就自己走原来那条分支。

反过来也是同一笔交易,值得把代价写清楚。这个插件自己的 inject 只写 harness 的服务(webServer、web surface bundle 提供的 webRuntime,浏览器侧的 slotsworkspaceslocale