@hyzyn/dsh-tty
中文 | English
DSH 侧边栏「终端」面板:xterm.js + 真实 PTY 的完整终端,本地与 SSH 一视同仁,长任务可断线保活。
特性
- 真 PTY + WebGL 渲染:node-pty 真实 PTY,vim / htop / dev server 等 TUI 均可运行;多标签页。
- 可选 tmux 会话持久化:宿主重启 / 网络抖动后重开即恢复现场;docker exec 这类「命令标签」自动重开。
- ssh2 原生连接:agent forwarding + 主机指纹 TOFU 钉扎,连接簿统一管理;另有 SFTP 上传下载与 端口转发(-L / -R,断线自动重连)。
- agent 侧按「命令」粒度取数:shell 集成(OSC 133/7)让
tty_capture{last} / tty_expect 拿到「上一条命令」的输出与退出码,而不是抓屏猜。
- 对其它插件开放两个客户端服务:
ttyConnbar(连接栏动作)与 ttyTerminal(就地开终端);dsh-docker 的「容器 / 终端」按钮即走这两个扩展点。
终端面板:多标签页 xterm 弹窗,工具栏含搜索/清屏/复制/粘贴,标题栏含最小化「—」与关闭 ✕
安装
dsh plugin --profile web add @hyzyn/dsh-tty # npm 安装(发布后)
dsh plugin --profile web add link:$(pwd)/packages/tty # 仓库开发调试
装完重启 dsh web,侧边栏出现「终端」入口,点击打开面板;设置 → 插件 →
「终端面板」卡片可改配置(保存即热生效,无需重启)。
使用
- 打开面板自动创建第一个终端(默认
$SHELL,macOS 上通常是 zsh);
- 多标签页:标签栏「+」新建终端(0.2.0 起「+」为菜单:本地终端 /
SSH 连接簿(条目带 ✎ 编辑)/ SSH 连接…,SSH 见下节)、标签 ✕ 关闭;
双击标签可重命名(重命名随标签持久化,断线恢复后保留);每个标签
独立会话(本地 PTY 或 SSH channel);
- 工作目录跟随当前 DSH 会话:新标签默认在当前会话工作目录打开
(宿主配置
cwd 作兜底)。0.1.6 起会话列表快照不再带 current(视图选中项搬到了
workspace 域),客户端改看 retainedBy.mainView > 0 认当前会话——只认老字段会让
cwd 恒为空、新标签回落宿主启动目录;
- 支持 vim / htop / less 等 TUI(TERM 已注入为
xterm-256color);
- 面板大小变化自动 resize(xterm fit → PTY 原生 resize);
- Ctrl+F 终端内搜索(Enter 下一个 / Shift+Enter 上一个 / Esc 只关搜索框),
输出中的链接可点击,工具栏提供 清屏 / 复制选中 / 粘贴;
- 断线自动重连(0.3.0):刷新页面、网络抖动等异常断开后,会话在宿主
保活
reconnectGraceSec(默认 120 秒),客户端指数退避自动重连(封顶 5s),
重连后按 sid attach 回原会话并回放断线期间的输出缓冲;页面刷新后从
sessionStorage 恢复标签列表(宿主侧已结束的会话自动丢弃);
- WebGL 渲染器(0.3.0):高吞吐输出(build 日志)渲染性能质变;WebGL
上下文丢失(多标签超出浏览器配额等)时自动回退 DOM 渲染器;
- 最小化(状态并入侧边栏入口):点弹窗外空白处、按 Esc 或标题栏「—」
把面板收起——PTY 会话与输出缓冲保持存活,侧边栏「终端」入口上显示
「运行中/总数」徽标与状态点(有输出时脉冲提示),点击入口即可恢复;
悬浮条 ✕ / 标题栏 ✕ 才真正关闭并结束全部会话;
- 标题栏 ✕ 关闭面板并结束全部会话(PTY 树级清理;tmux 持久标签同时
kill-session);会话退出后点终端区域可重开;
- 并发上限默认 4(配置
maxSessions,1~16)。
终端面板设置卡片:shell / TERM / 并发上限等保存即热生效
Windows 宿主(0.18.1,best-effort)
0.18.1 之前,Windows 宿主上的本地终端一条都开不起来,两处都在同一条路径上:
$SHELL 在 Windows 上根本不存在,默认 shell 无条件回落 /bin/zsh → spawn 直接
ENOENT(实测:spawn /bin/zsh -> ENOENT,而 %COMSPEC% 可用);
- 启动计划是 POSIX 形状的
-c 'export TERM=…; export COLORTERM=…; exec "$shell"' ——
cmd.exe 不认 -c(忽略整行、空跑一场就退出,标签开了就消失),PowerShell 认 -c 但把
export 当不存在的 cmdlet 报错。
两条都在 Windows 11 ARM(24H2)+ Node 22 ARM64 上实测复现,修法:
- 默认 shell =
%COMSPEC%(系统保证存在);要 PowerShell 就在设置卡片把「Shell 路径」
填成 powershell.exe / pwsh.exe 的完整路径。Windows 上候选列表给的是 %COMSPEC% +
Windows PowerShell 5.1 + PowerShell 7(装了才有),默认项排最前;
- Windows 上不做包装层:直接
[shell];PowerShell 家族补 -NoLogo(去掉版权横幅),
不补 -NoProfile —— 用户的 profile 正是别名与函数的来源。TERM / COLORTERM 对 ConPTY
没有意义,也不再注入;
- 「跑一条命令」的标签(docker exec、agent 起的命令标签)走
cmd /c 或
PowerShell -Command;
- 不支持的三项(宿主侧自动关掉,设置卡片里写明原因):
- shell 集成(OSC 133/7):注入靠 POSIX rc 桩 +
-c 包装层,cmd / PowerShell 上都不成立,
因此恒关 —— 本地标签的 tty_list.cwd 跟随 cd、tty_capture{last} 与 tty_expect
的命令粒度不可用(远程 Linux / macOS 主机照旧支持);
- tmux 持久化:Windows 上没有 tmux,本地标签照常打开但不受托管(SSH 到 Linux 主机仍可用);
- 服务器状态条:本地只拿得到 CPU / 内存 / 在线时长(
node:os),磁盘 / TCP / 网速 / 温度
显示「无」(远端 Windows 主机走 PowerShell 那一跳,见「服务器状态条」一节);
- 验证到什么程度:Windows 11 ARM 上跑过完整安装(
dsh plugin add @hyzyn/dsh-all)、九个插件
装载、dsh web 起服务与浏览器半体交付(与 macOS 上服务的是同一份产物,构建后比对大小与
内容标记一致;不写死字节数——每次重建都会变)、[dsh-tty] mounted (shell=C:\WINDOWS\system32\cmd.exe);x64 Windows 由 CI
的三平台矩阵覆盖(build / typecheck / test,见「开发」)。
- 端到端覆盖(0.19.0):
scripts/windows-smoke.mjs 在 CI 的 windows-latest 上跑真实 cmd.exe
的 spawn → 输入回显 → kill → 重开。它抓出并钉住了「强杀本地 PTY 会崩宿主」这类只在 Windows
出现的问题——node-pty 在 Windows 上不接受 signal,而它的 _deferNoArgs 会把这个异常推迟到
socket 回调里抛出,调用方的 try/catch 拦不住。
agent 工具(P1)
插件向 agent 注入十三个工具(与 bash 工具同权,操作实时显示在用户终端里):
| 工具 | 作用 |
|---|
tty_list | 列出活跃终端会话(sid / kind(local|ssh)/ target / pid / cwd 实时跟随 cd / 活动时间;tmux 持久会话带 persist 标记) |
tty_capture | 读取近期输出(尾部 N 行,默认清洗 ANSI,raw:true 取原始流);last:true 只返回上一条已完成命令的输出 + 退出码(shell 集成标记,见下节);命令在途时(刚发送、完成标记未到)返回 inProgress:true 且不带旧结果——避免把上一条的输出当成这一条(0.19.0) |
tty_screen | 读取当前可见屏幕的渲染结果(xterm-headless 虚拟屏,纯文本)——能真正读懂 vim / htop / 菜单等 TUI 界面 |
tty_expect | 用正则等待后续输出中的就绪信号(dev server URL、构建完成等);超时不抛错(matched:false + 尾部输出),命令提前结束也会带退出码早停;同一会话在途调用最多 5 个,累积窗口只保留尾部 64KB(0.19.0) |
tty_send | 向指定会话发送按键/文本(如 dev server 的 q 键、菜单选择) |
sftp_list | 列出 SSH 远程目录内容(名称/类型/大小/修改时间,目录在前);book 为连接簿条目名,path 缺省为登录 home;默认最多 500 项(超限 truncated:true),isSymlink 区分软链与真目录(0.19.0) |
sftp_read | 读取远程文本文件(默认 ≤256KB 可调至 1MB,超出截断);offset 可从指定字节分页(适合读日志尾部),非法 maxBytes 直接报错,二进制判定 = NUL + 非法 UTF-8 占比双判据(0.19.0) |
sftp_write | 写远程文本文件(默认覆盖,append:true 追加;单次 ≤1MB) |
sftp_mkdir | 创建远程目录;parents:true 逐级补齐缺失父目录(等效 mkdir -p,自底向上创建,已存在目录幂等跳过) |
sftp_rename | 重命名/移动远程文件或目录(to 与 from 不同目录即移动;不覆盖已存在的目标) |
sftp_remove | 删除远程文件/目录;目录默认 rmdir(非空明确报错),recursive:true 整树删除(不可恢复);会拒绝 /、~、含 ./.. 段的路径(不可恢复操作的前置护栏,0.19.0) |
sftp_tree | 递归列举远程目录结构(深度优先、目录优先;maxDepth 18 / maxEntries 12000 限流,超限 truncated:true;symlink 不跟随防环) |
tunnel_list | 列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、连接数) |
典型 agent 流程(推荐):tty_send 启动长任务 → tty_expect 等就绪标记 →
tty_capture{last:true} 拿单条命令结果。此外 systemPrompt 里注册了动态
context,每轮对话自动携带活跃终端快照(sid / kind / cwd),无需先调
tty_list 也有上下文。
shell 集成(OSC 133/7,0.4.0)
spawn 时经既有的 -c 包装层按 shell 类型注入钩子(对用户透明,不改 rc):
- zsh:
ZDOTDIR 指向临时桩目录(VS Code 同款方案),桩文件先 source
用户原 rc 再追加 precmd/preexec 钩子;
- bash:
--rcfile 桩(先 source ~/.bashrc);命令开始标记按版本二选一:
bash ≥ 4.4 走 PS0;bash < 4.4(macOS 自带 3.2)无 PS0,用 DEBUG trap
兜底(handler 按 $BASH_COMMAND 过滤掉 PROMPT_COMMAND 机制自身的
fire,避免幻影标记把用户输出切出捕获区间;bash 3.2 的
trap - DEBUG 在 handler 内卸载不生效,故按「永久武装 + 过滤」设计)。
副作用:循环体等复合命令的内部命令会多发 B 标记,仅影响
tty_capture{last} 对这类命令的截取起点,D/退出码与 tty_expect 不受影响;
PROMPT_COMMAND 挂钩兼容字符串与数组(bash 5.1+)两种形态;
- 标记语义:
133;A prompt 开始 / 133;B 命令开始 / 133;D;<exit> 命令
结束带退出码 / OSC 7 file://… cwd 上报(tty_list.cwd 跟随 cd,
SSH 会话则上报远程路径);
- 其他 shell 静默关闭;配置
shellIntegration: false 可整体关掉(逃生门)。
SSH 会话同表调度:tty_list 里 kind: 'ssh' 的条目按 target
(user@host[:port])识别,tty_capture / tty_expect / tty_send 用法与
本地会话完全一致——远程机器上的 dev server 日志与按键交互照常可用。
凭据存储(连接密码不必只落明文)
连接对话框的「密码」下面有一行 凭据存储:勾上「保存时存入凭据存储」,密码就写进
官方凭据存储,字段里只留 env:NAME 引用(值在 ~/.dsh/.credentials.yaml,不进环境、
不回传浏览器)。这个勾选框默认就是勾上的(宿主提供了 remote.credentials 时):输明文
再保存 → 值进存储、设置里只留引用,比把密码明文写进设置文件更好(设置会被送到浏览器,凭据
存储的值永不回传);宿主没有这个服务时它会被拨回未勾 + 禁用,并说明只能明文保存。
-
跟着保存走,不额外点一次:勾选后由「保存修改」/「连接(并保存)」执行写入——没有
"存了但没保存"的悬空引用。没勾「保存到连接簿」时不会动存储(那时没有配置可依附,
只会留下一个没人引用的孤儿)。设置卡片的行内编辑是同一行、同一个默认值,只是提交入口
叫「应用」(那张卡片的行级提交就是「应用」,随后随卡片「保存」落盘),所以勾选框文案是
「应用时存入凭据存储」;代价同一个:值先落存储,若此后放弃「保存」,存储里会留下一个
尚未被引用的名字(引用选择器里可见、可清)。
-
默认勾选的两个副作用(知道就不会意外):① 编辑一条明文密码的老连接、只改别的字段
再点「保存修改」,密码也会被搬进存储(设置里换成引用)——不想搬就取消勾选;② 存储拒绝
写入时(典型是引用被只读源遮蔽)保存会被中止并把官方的原文错误显示出来,而不是偷偷
改成明文落盘(那个引用名带 DSH_TTY_ 前缀 + 哈希,实际几乎不可能被遮蔽)。
-
布局克制:明文态这一行只有一个勾选框;字段已是引用时才换上「清除已存凭据」与状态
(已存入(来源 file)· 引用名)。完整的安全边界写在勾选框的悬停说明里,不铺在对话框里。
-
模型:配置持引用、值归存储。与 SecureCRT 的「命名凭据集按标题引用」、iTerm2 的
「按名从密码管理器取」是同一派;实现走 DSH 官方的 ctx.remote.credentials
(describe / set / unset,值单向写入、没有任何读路径),与官方设置卡片同款。
-
名字(规则恒定):DSH_TTY_<用户名>_<主机>[_<端口>]_<字段>,端口留空或 22(默认)时省略
—— 与连接侧的 spec.port ?? 22 一致(留空即 22),所以"留空 / 22 / " 22 ""三种写法归成同一个键,
同一个账号不会有两个名字、同一个密码不会存两份;非默认端口进键(同一主机不同端口常是 NAT 后面的
不同盒子)。字段 = PASSWORD / PASSPHRASE;例 hsadmin@192.0.2.10:22 →
DSH_TTY_HSADMIN_192_168_80_248_PASSWORD。按资源身份派生、不带哈希——与
git-credential-store 的 protocol://username@host、docker credential helpers 的
ServerURL + Username 同一派:host 与 username 本来就是 ASCII 标识符,不需要清洗、
也就不需要哈希兜底。曾经的哈希版是在补救"把人类标签清洗成键":引用文法只认 ASCII,
HS 248 / lab-a / HS_248 折出来完全一样,只能靠哈希避免静默覆盖 ✗。资源身份没有这个
死结——撞名只可能发生在同一主机、同一用户、同一端口,而那本来就该是同一个密码(共享是
正确行为)。连接名完全不参与键,所以改连接名/改备注都不会换键。空主机或空用户名则拒绝
存入(键的全部来源,缺一就退化成常量)。代价是可读性弱于人类标签:本对话框的「存入」只按
上面的规则命名,不接受自定名字 ✗;要复用一个自定义名字,就在字段里直接手输 env:名字
(前提是那个名字已在凭据层有值——例如由官方设置卡片 / env 卡片托管)。
-
派生只发生在「存入」那一刻:存完以后配置里那个 env:NAME 就是唯一事实来源,没有任何地方
会再派生一次——所以改连接名不会让已存的值失效,也不会留下孤儿(旧哈希版才会:连接名一进
键,改名后重新存入就会留下旧名字。要清就按字段里的引用用「清除已存凭据」清掉)。
-
共享:引用是扁平命名空间,任何按同一套解析的消费方都能用——例如填进 dsh-docker 目标的
password,同一个密码两处复用。
-
可见性:对话框的引用选择器会列出存储里已有的名字——宿主侧
/api/dsh-tty/credential-refs 只读 的键名回来(只要名字,值永远不出宿主)。为什么要自己
读:引用半边在协议上(原因写在选择器那节),而"我存过哪些名字"正是这个选择器要回答
的问题。这也让(例如改过命名规则后留下的旧名字)重新可见、可选、可清。
端口转发(0.5.0)
设置 → 插件 → 终端面板 卡片的「端口转发」区块维护隧道;每条隧道引用一条
连接簿条目(主机与认证随之),两个方向:
- 本地转发(-L):本地
127.0.0.1:localPort 监听 → 经 SSH 在服务端侧
连到 remoteHost:remotePort——把远程数据库/内部服务映射到本地(最高频
用法:localPort=5432 → db.internal:5432);
- 远程转发(-R):服务端监听
remoteHost:remotePort(缺省
127.0.0.1)→ 入站连接拨回本地 localTargetHost:localTargetPort——把本机
dev server 暴露给远程/内网;
- 宿主自持生命周期:隧道与终端标签互相独立(各有各的 SSH 连接),面板
关了隧道照跑;SSH 断线自动指数退避重连(1s→15s 封顶),remote 方向重连
后自动重新 forwardIn;连接簿改密码后重连自动用新凭证;
- 状态徽标:卡片展开期间 2s 轮询实时状态(活跃绿/连接中蓝/错误红/停止
灰 + 最近错误);「+」菜单的连接簿条目显示
⇄N 隧道徽标;agent 可用
tunnel_list 工具查询状态;
- TOFU 与终端会话共享同一份 hostKeys 钉扎;端口不占用 maxSessions 名额。
SFTP 文件传输(0.7.0,0.8.0/0.9.0 增强)
不动终端、不占会话名额,直接对 SSH 连接做远程文件操作(ssh2 的 sftp
subsystem,宿主半体 src/sftp.ts):
SFTP 单窗体(0.16.0 起挂在终端下方抽屉):远程目录浏览,行内 下载/重命名/删除
SFTP 双栏(0.9.0;0.16.0 起挂在终端下方抽屉):左本机 / 右远程,行内 ⇨/⇦ 服务端直传
- 落点(0.16.0):终端面板开着且本标签的挂载位空着时,文件浏览挂在终端下方——路径栏 /
列表 / 传输进度占满整宽(文件列表是横向宽表,下方全宽比右侧窄栏好用,终端也保住
宽度不会折行;双栏两栏并排更需要这个宽度),终端继续可见可用。高度可拖、可折叠成
一条标题栏(折叠不关面板、不中断浏览);同一标签的挂载位已被别的面板(如容器面板)
占用、或面板没开时,退回原来的居中对话框,不会把别人的面板挤掉。标题 / 折叠 / ✕ 由
tty 的挂载位提供;
- 跟着标签切(0.19.0):文件浏览连的是打开它的那个标签那台主机,所以它归属该标签:
切到别的标签时整块收起(在途传输照跑,切回来还在原地),标签关掉时一并收掉。所有入口
用同一条规则——连接栏「SFTP」、连接簿条目的 📂、SSH 对话框 / 设置卡片的「文件浏览」都
归属打开那一刻的活动标签;只有「面板开着但一个标签都没有」才算不隶属任何标签(永远可见)。
此前不认归属,切完标签面板还停在原处——标题写着 A 主机、底下活动标签是 B,最坏会往错主机上传;
- 入口:① 标签栏「+」菜单的连接簿条目带 📂(按该条目打开文件浏览);
② SSH 连接对话框填好主机/认证后点「文件浏览」(不落连接簿也能浏览);
③ SSH 标签的连接栏「SFTP」按钮(归属该标签,见上一条);
- 操作:目录浏览(路径框回车跳转、
..(上级目录)、单击文件即下载)、
上传(多选文件,XHR 流式 + 进度百分比;0.8.0 起支持拖拽——文件与
文件夹直接拖入对话框,文件夹经 webkitGetAsEntry 递归展开逐个上传,目录
用 mkdir parents 逐级补齐)、下载(POST → 浏览器 Blob
→ <a download>)、新建目录、重命名(行内编辑器)、删除
(🗑 二次点击确认,目录带 recursive 整目录删除);
- 传输进度条(0.11.0):底部状态行右侧新增细进度条 + 百分比——上传按
XHR 流式进度(多文件带
i/n · 文件名 标签);下载改为流式读
response.body,按响应 content-length 实时算百分比(无长度时降级为已传
字节文本);
- 取消传输(0.12.0):进度条右侧 ✕——上传中断在途 XHR(批次里剩余
文件一并跳过);下载用
AbortController 打断流式读取;双栏 ⇨/⇦
直传改为服务端任务化(start 返回 jobId → 400ms 轮询真实字节进度 → ✕ 打
cancel 中止),不再是一个打不断的同步 HTTP 请求。取消后半截文件自动
删除(上传的远端残留文件 / 下载的本机残留文件;清理失败只记日志),状态行
走「已取消」而非红色失败态;关闭文件浏览对话框也会收掉在途传输,不留
「看不见但还在写远端」的后台搬运;
- 连接管理:懒连接池——首次操作才建 SSH 连接,空闲 120 秒自动回收,
断开后下次操作自动重连;连接簿条目在每次(重)连接时实时解析(改密码
后自动用新凭证);TOFU 与终端会话/隧道共用同一份
hostKeys 钉扎,指纹
变同样拒绝;SFTP 不计入 maxSessions 名额;
- 传输通道:
POST /api/dsh-tty/sftp/list|mkdir|rename|remove|download| upload(全部 loopback 围栏)。连接规格经 JSON 体(连接簿名或内联字段,
与 WS ssh 帧同语义「条目作基底 + 内联逐项覆盖」)或 upload 的
x-dsh-sftp-meta 头(base64url)携带——凭证不进 URL/查询串;上传下载
均为流式 pipe,不整文件进内存;
- agent 工具:
sftp_list / sftp_read / sftp_write / sftp_mkdir /
sftp_rename / sftp_remove / sftp_tree(见上表)——只收
book 连接簿条目名,不接受内联凭证(agent 上下文不进明文密钥);
- 双栏风格(0.9.0 可选,配置 ):左本机 / 右远程两栏——本机
侧浏览与文件操作走新增的 路由(list/mkdir/rename/
remove,loopback 围栏);行内 把条目对拷到对面栏的当前目录
( 由宿主服务端把两个路径流式直传,目录
递归、同名覆盖,);单窗体风格照旧,设置卡片切换,
重新打开 SFTP 生效。
会话持久化(tmux,0.10.0)
默认安全模型不变:会话随宿主生死(内核级 PTY 清理)。需要「活得比宿主久」
的工作(dev server、build、训练任务),开持久终端——会话状态委托给
tmux server(专用 socket dsh-tty,与用户自己的 tmux 完全隔离),断线保活
超时、甚至宿主重启后都能接回:
- 入口(0.10.1 简化):设置卡片「会话持久化」选
tmux 即唯一开关——开启后
所有新开的标签默认持久化:「+」菜单的「本地终端」、连接簿条目点击、
SSH 连接对话框(「持久会话」默认勾选,单次连接可取消)。不再有单独的
「持久终端」菜单项;
- 条目级取消(
sshHosts[].persist):连接簿条目上的「持久会话」勾选框存的是取消项
——显式取消过(persist: false)的条目点开时不再 tmux 托管,其余条目(true /
没写过)跟随全局开关(所以 ~/.ssh/config 导入的条目不受影响)。设置卡片与连接对话框
都只有全局开关开着时才显示这个勾选框;在设置里编辑条目不再丢掉它(0.17.x 之前只改
一个用户名就会静默把它清成 false);
- 机制:spawn/ssh 帧带
persist + 客户端生成、随标签规格保存的稳定
persistName——本地把 -c 包装层换成 exec tmux -L dsh-tty -f <conf> new-session -A -s dsh-<名>(cwd 由 node-pty spawn 继承);SSH 则
远程 exec tmux -L dsh-tty -f /dev/null new-session -A -s dsh-<名> 开
pty channel。-A attach-or-create:宿主重启后重开标签按同名接回原
tmux 会话,正在跑的程序、pane 状态原样恢复;
- 恢复链路:浏览器重连后查
sessions——持久标签 sid 已失效的,客户端
自动按原 persistName 重新 spawn(非持久标签维持丢弃语义);保活回收器
超时只杀 PTY(tmux 客户端),不杀 tmux 会话,回收后照样可接回;
同宿主内的重连(attach)对 tmux 会话不回放宿主缓冲——回放会把可见屏
先写进全新 xterm(幽灵滚动条),tmux 整屏重画再画一遍(重影)——改为
强制 tmux refresh-client 重画恰好一次;
持久标签规格同时写入 localStorage(sessionStorage 只对同一浏览器标签
可见,dsh 重启自动打开的新窗口原本读不到、恢复就断在这一环)——全新
窗口打开面板时按规格直接 respawn:tmux 会话存活则接回原现场,已消失
(远程重装/重装丢失)则开新 shell;规格只随「标签被主动关闭/退出」淘汰,
不做事前存活确认(确认依赖的留存状态一旦漂移会让恢复静默失效);
- 关闭语义:kill 帧(标签 ✕ / 关面板)对 tmux 背书会话先
tmux kill-session 再杀客户端——真正结束,而不是 detach 留活口;
整个页面关闭时默认留存(保活期后可再恢复),配置
endOnPageClose: true 则改为保活期结束时连 tmux 会话一起结束;
- shell 集成兼容:tmux 会吞掉不认识的转义序列——钩子检测
$TMUX 把
OSC 133/7 包进 DCS passthrough 信封(payload 内 ESC 双写),tmux ≥3.3 +
allow-passthrough on(宿主自动写入桩 conf)时解包转发,宿主解析器看到的
仍是裸标记:tty_expect / OSC 7 cwd 跟随在持久标签内照常工作;
tty_capture{last} 另有一层修正——tmux 的 pane 重画是异步批量的,命令输出
会落在 D 标记之后逃出捕获窗口,故钩子在发 D 前先 capture-pane 把 pane
内容经 OSC 133;T(base64,最近 200 行)随流直送,宿主优先采用 T 快照
作为命令输出(超出 200 行的输出截断头部,与环形缓冲语义一致);
- 运行资产:
<DSH_HOME|~/.dsh>/tty/ 下的稳定桩目录(tmux server 比宿主
进程活得久,不能用临时目录):tmux.conf(status off 防重绘污染捕获、
真色 overrides、default-command 指向内层启动器)与 inner.sh(按当前
配置 exec 内层 shell,zsh ZDOTDIR / bash --rcfile 桩照常注入);配置热改
对新开 pane 生效,tmux.conf 本身只在 tmux server 首启时读取;
- 降级:本机/远程未安装 tmux 时,持久 spawn 自动落回普通会话,终端里
回一行灰字提示;不装任何东西也能正常用,只是无持久化。
SSH 连接
0.2.0 起标签栏「+」变为一键菜单,除本地终端外还能开 SSH 标签页:宿主
半体用 ssh2 原生建立连接并打开 shell channel(不经过本地 ssh 进程,也
不占 node-pty),包装成与本地 PTY 完全一致的会话对象——输入、resize、
关闭、输出缓冲、背压与 agent 工具全部复用同一套调度。
- 「+」菜单三个入口:本地终端 / SSH 连接簿(配置里保存过的条目,
显示
user@host[:port] · auth,条目带 📂 文件浏览与 ✎ 编辑)/ SSH 连接…
(表单手填 host / port / username / auth,连接前可勾选保存,对话框底部
「文件浏览」可跳过终端直接以当前信息打开 SFTP);
- 连接簿:SSH 连接对话框勾选「保存到连接簿」即存为条目(同名覆盖,
名称留空用主机名);「+」菜单条目的 ✎ 与设置卡片里的 编辑 走的是同一套表单——
连接 / 认证 / 选项 三段分组 + 凭据存储 + 凭据引用选择器 + 试连 + 文件浏览,
支持改名、同名冲突校验。两者只差提交方式:菜单对话框是「保存修改 / 连接(并保存)」
立即落盘,设置卡片是「应用」进卡片表单、再随卡片「保存」写入配置;
- 认证方式(auth)三选一:
agent(默认)——走 ssh-agent(SSH_AUTH_SOCK),凭证不落盘,最推荐;
key——keyPath 私钥文件(~ 开头可省略 home),passphrase 可选;
password——密码认证,同时挂 keyboard-interactive(不少服务端只开这个);
- 密码 / 口令支持
env:VAR:password / passphrase 填 env:MY_SECRET 时按凭据层解析 ——
官方凭据 provider 优先(叠 $DSH_HOME/.credentials.yaml / 进程环境 / project-env /
user-env,每次连接重新解析),它没有才退回宿主进程环境变量(配合 dsh-env-manager 插件托管
密钥,避免明文写进 settings 文件);解析不到时报错会同时点到"引用名"与"两处都没有";
- 端口:默认 22,非 22 端口在 target 里显示为
user@host:port;
- 标签与状态:SSH 标签标题用连接名或
user@host(本地标签是
「终端 N」);连接中先回显灰字 Connecting user@host …,就绪后状态栏
显示 SSH user@host 已连接;连接失败(连接超时 / 认证被拒 / 主机
不可达)以 error 帧带回原因,标签规格已随标签保存,点终端区域可按
原规格重开;状态栏描述的是当前活动标签——切标签 / 关标签立刻换成
该标签自己的状态(失败原因、退出码都记在标签身上),所以关掉那个连不上
的标签后,红字不会继续挂在头上(宿主级消息如「连接断开 — 自动重连中」
不属于任何标签,不会被切标签抹掉);反过来说,后台标签自己的失败只记在
它身上(标签栏状态点转红、终端区浮层带原文),不会顶掉活动标签的显示;
- agent forwarding(0.4.0):SSH 对话框勾选「agent forwarding」后远程
可用本地 ssh-agent 的钥匙(远程
git clone 私有仓库等)。任意认证方式下
都可开(凭证仍不落盘);本机未运行 ssh-agent 时连接会明确报错而非静默
失效。连接簿条目随 agentForward 保存,列表里显示 · fwd;
~/.ssh/config 导入(0.4.0):设置卡片连接簿区「从 ~/.ssh/config
导入」——解析 HostName/User/Port/IdentityFile 生成候选条目(跳过通配符
块与无 User 条目,Include 不展开),同名跳过,随「保存」写入;
- 凭据引用选择器(0.4.0,0.17 起与 env 插件解耦):SSH 对话框的密码/口令字段旁有筛选框 +
限高列表,候选 = 凭据存储里已有的引用名(宿主读
.credentials.yaml 的 refs: 键,
只回名字、绝不含值)∪ 本机连接簿里已经在用的引用名;点击即填 env:NAME,也可手输
任意 (连接时按解析:provider 优先、 兜底)。
:引用的发现路径官方定的是"配置界面从 得知有哪些引用"——引用半边(
的 注释原话:"the reference half, which has no enumeration because configuration
surfaces learn which references exist from settings schemas"),浏览器侧的
也只开 / / ,连 都没开。于是
"这本存储里到底存过哪些名字"在浏览器侧根本问不到 ✗ —— 而选择器的用途恰恰就是"我存过什么、
能不能复用"。所以宿主侧开了一条通路 (loopback 围栏,
只解析 的键名、不返回值,见 )。这是官方"引用不可
枚举"设计的一处(代价:引用名会进浏览器,值不会);若要完全守官方口径,就只用连接簿那一半。
边界:本地 provider 若被配了自定义 / ,那些引用这里看不到;宿主读取失败或旧
宿主没有这条路由时,候选安静退回连接簿那一半。
候选一个都没有时,这一行(不再摆一个永远点不开的下拉——那看着就像坏
了):文案点明要么勾上面「保存时存入凭据存储」新建一个,要么在字段里直接手输 ;
口令那行上方没有勾选框,文案相应改成只提手输。
(同样只在筛选框获得焦点时展开),差别只在呈现:卡片里是列表而非浮层——卡片本身是
可滚动的长表单,浮层在那边会被裁掉;
服务器状态条(0.17.0)
终端面板(含服务器状态条):终端上方一条 CPU / 内存 / 磁盘 / 核心 / 在线 / TCP / 网速的细监控条
(图中是本机会话;SSH 会话的状态条同款同字段——见下方 SFTP 单窗体那张图,终端上方那条就是远端主机的指标。)
每个可见标签在终端上方挂一条细状态条,实时展示该会话所属主机的资源指标
(观感对齐 FinalShell 的会话监控条):
CPU ▮▮▯▯ 6% │ 内存 ▮▮▮▮ 78% │ 磁盘 ▮▮▮ 63% │ 核心 16 │ 内存 49.6G/62.3G │
在线 2w4d7h16m │ TCP 570 │ 磁盘 40.8G/68.8G │ CPU温度 无 │ 网络 ↓92.3K/s ↑93.1K/s
- 两条采集路径,同一帧形状:SSH 会话在同一条 ssh2 连接上另开一条
非 PTY 的 exec channel,远端跑常驻循环每秒吐一行 JSON(CPU%/网速等速率
在远端算好);本地会话由宿主自己采(Linux 直读 /proc,macOS 走 os/netstat/
vm_stat)。两条路径都不碰 PTY 数据流。
- 远端平台覆盖 Linux / Windows:默认跑 POSIX sh + awk 脚本;若 channel 在
一帧数据都没读过时就结束(Windows 上 cmd.exe / PowerShell 解析不了脚本),
自动换 PowerShell 版再试一次(
-EncodedCommand 递送、同样字段形状),两跳都
失败才判失败——macOS/BSD 远端就是这条路径(无 /proc 也无 PowerShell)。
- 订阅制、懒启动:客户端按标签可见性发
{t:'statsOn'} / {t:'statsOff'},
宿主首个订阅才起采集;退订清零、会话退出、孤儿回收、插件禁用、配置关闭
都会停表并关掉远端 channel,不留定时器/远端循环。
- best-effort:某项拿不到就省略该字段(前端显示「无」),采集失败静默停表、
状态条整条隐藏(8s 收不到新帧即收起——不是 3s:宿主某个采集子进程偶发变慢时帧间隔
会被拉长,窗口太紧就会整条一闪一闪)——绝不写 PTY、绝不弹错误。进度条阈值
配色:<70 正常 / 70~90 黄 / >90 红。
- 慢命令不拖垮节奏:走子进程的字段(df / netstat / vm_stat)只在首次采样时等一次,
之后一律「用上一次的值 + 到点后台刷新」,某台机器某个命令卡住只会让那一格短暂显示旧值,
不会把「每秒一帧」的时间轴拖散(实测 3000ms/帧 → 3~12ms/帧)。
- 热生效:关掉
statsEnabled 立刻停采集(状态条消失、WS 不再发 stats 帧),
重新打开后按仍存留的订阅自动恢复,无需重启或用例重开标签。
- 固定槽位 + 增量刷新:每个值的字符槽位固定(右对齐,最宽的一档照样只占
自己那一格),条目只建一次、之后只写真的变了的文本。所以
CPU 5% → 12%、
TCP 36 → 1024、内存 9.2 GB → 17.8 GB 都不会把后面的条目推着横移(曾表现为整条
每秒跳一次),进度条宽度也能真正走 CSS 过渡;窄窗口下横向滚到右侧看 网络 时,
滚动位置不会被下一秒的刷新弹回开头。
配置(设置 → 插件 → 终端面板,保存即热生效)
| 项 | 默认 | 说明 |
|---|
enabled | true | 关闭整个插件(需重启生效) |
announceToAgent | true | 是否向 agent 公告终端面板能力(systemPrompt 注入) |
maxSessions | 4 | 并发 PTY 会话上限(1~16) |
shell | $SHELL | shell 路径;设置卡片可选可输入(下拉候选来自 /etc/shells + $SHELL + 常见安装路径,仅列存在且可执行者,$SHELL 优先),也可手输任意路径 |
term | xterm-256color | TERM 值 |
colorTerm | truecolor | COLORTERM 值 |
cwd | 宿主启动目录 | 兜底工作目录(客户端当前会话 cwd 优先) |
reconnectGraceSec | 120 | 异常断开后会话保活秒数(0~3600):刷新页面/网络抖动后会话存活等待重连,超时由回收器结束;0 = 旧行为,断开立即结束 |
sshHosts | [] | SSH 连接簿(面板「+」菜单可选):条目 {name, host, port=22, username, auth=agent|key|password, keyPath, passphrase, password, agentForward, persist=false};保存时整体替换、同名覆盖;password / passphrase 支持 env:VAR 引用,避免明文入库;持久化开启时条目点击默认以 tmux 持久会话打开,persist=false 是取消项 |
hostKeys | [] | SSH 主机指纹记录(TOFU,自动维护):条目 {host, port, fingerprints[]}(旧版单 fingerprint 字段读取时自动迁移合并);按 host:port 唯一,一机多把钥匙共用一条记录,首次连接自动追加、任一指纹匹配放行、全部不匹配拒绝连接;设置卡片可删除重置 |
shellIntegration | true | 注入 OSC 133/7 shell 集成(命令边界标记 + cwd 上报;tty_capture{last} 依赖它);zsh/bash 支持,其他 shell 自动跳过;出兼容问题时可关闭 |
tunnels | [] | 端口转发隧道:条目 {name, bookName, direction=local|remote, localPort?, remoteHost?, remotePort?, localTargetHost?, localTargetPort?, enabled};bookName 引用连接簿条目提供主机与认证;卡片「端口转发」区块可视化维护 |
sftpStyle | dialog | SFTP 文件浏览界面风格:dialog 单窗体(远程目录 + 上传/下载/拖拽)/ dual 双栏(左本机 / 右远程,行内 ⇨/⇦ 宿主服务端直传);重新打开 SFTP 生效 |
连接栏扩展点(客户端服务 ttyConnbar,0.13.0)
其他插件可以在 SSH 连接栏(SFTP / 隧道按钮那一行)追加自己的上下文按钮,而不需要
tty 认识它——tty 只暴露一个通用客户端服务。内置动作(重新打开 / SFTP / 隧道)也
走同一条注册通道,显示顺序 = 注册顺序;未注册任何扩展时行为与之前完全一致。
// 消费方(如 dsh-docker)在自己的客户端半体里可选注入:tty 没装就不会触发
ctx.inject(['ttyConnbar'], (c) => {
const dispose = c.ttyConnbar.addAction(({ tab, spec, bookName, addAction }) => {
// 每次 renderConnbar 都会调用一次;自行决定这次要不要加按钮
if (spec.t !== 'ssh') return
addAction(iconSvg, '容器', '打开该主机的 Docker 容器面板', () => { /* 打开自己的面板 */ })
})
// 卸载时调用 dispose()
})
| 成员 | 说明 |
|---|
addAction(factory) | 注册按钮工厂;返回注销函数。factory 收到 {tab, spec, bookName, addAction}:spec 是会话的 spawnSpec({t:'ssh', name?, host, port, username, ...}),bookName 是连接簿条目名(内联连接为 ''),addAction(icon, label, title, onClick) 用 tty 的按钮样式追加一个按钮 |
requestRender() | 请 tty 重新渲染连接栏(消费方异步拿到新数据后需要按钮立刻出现时用) |
- 只在 SSH 标签上触发;本地标签的连接栏本身是隐藏的。
- 命令标签不触发(
spawnSpec.command 非空,即经 ttyTerminal.open 跑一条命令的标签,
如 dsh-docker 的 docker exec -it …):连接栏那几个扩展都作用于连接本身,挂在命令
标签上会误导(SFTP 浏览的是宿主机,不是用户以为的容器内)。内置与第三方动作一起隐藏;
退出态的重开入口不在此列——终端体内浮层本来就有「点击重新打开」。
- 工厂抛错只记
console.warn,不影响连接栏与内置按钮。
- 服务名
ttyConnbar 未声明在 tty 的 Context 类型面上,消费方用字符串注入即可;
tty 未安装或版本 < 0.13.0 时注入不会触发,消费方需按可选依赖处理。
open() 与 ttyPanel.mountPane() 都会让面板可见:没开就开,最小化中则恢复。
(曾经只判「弹窗是否存在」,于是面板最小化时新标签被加进一个隐藏的弹窗,消费方看到的是
「点了没反应」——minimize() 是隐藏而不是关闭,这一条对调用方是硬保证。)
终端命令标签(客户端服务 ttyTerminal,0.14.0)
open() 默认复用(契约 v3):同一个「连接 + 命令」已经有活标签时,聚焦它而不再新开,
返回被复用的那个标签。动机是实测——容器卡片「终端」按钮连点几下就是三个一模一样的
docker exec 标签,而每个标签各占一个会话名额,并发上限被白白吃光,接着满屏都是
「会话数已达上限」。想要并列两个同样的会话,传 reuse: false。
(复用键按固定字段列表构造,不直接 JSON.stringify——从 sessionStorage 恢复的 spec 与现场
构造的键顺序未必一致,用字符串化会漏判。)
比连接栏按钮更进一步的扩展点:让其他插件开一个标签直接跑一条命令(典型用途
是 dsh-docker 的卡片「终端」按钮 → docker exec -it <容器> sh)。
ctx.inject(['ttyTerminal'], (c) => {
c.ttyTerminal.open({
command: "docker exec -it 'ems-consumer-test' sh", // 必填,单行,≤2000 字符
book: 'lab-a', // 二选一:连接簿条目名 → SSH 标签
// spec: { host, port, username, auth, agentForward }, // 内联 SSH 字段
// (都不传 = 本地标签,用 cwd 指定工作目录)
label: 'ems-consumer-test · exec',
cwd: '/optional/local/cwd',
})
})
- 命令标签不做 tmux 持久化(命令短命,attach 无意义),也不走登录 shell;
SSH 侧用
conn.exec(command, {pty}),本地侧用 sh -c 'export TERM=…; exec <command>'。
- 命令标签会自动重开:宿主重启 / 断线重连后 sid 已失效,客户端对
spawnSpec.command 的标签按原规格重新执行命令(普通非持久标签维持「点击重试」
的旧行为)。页面刷新后同样按原命令恢复。
- 命令来自宿主侧插件(不是远程用户输入),信任级与插件本身相同;tty 只校验
形状:非空、单行、长度 ≤2000(换行会破坏本地
-c 包装层)。
- 服务名
ttyTerminal 同样未声明在 Context 类型面上,按可选依赖注入;tty 未安装
或版本 < 0.14.0 时不会触发(dsh-docker 会退化为「复制命令」)。
就地嵌入终端(ttyTerminal.mount,0.15.0)
open 是「借 tty 的弹窗开一个标签」——用户的面板会被弹窗盖住/被顶到后面;如果消费方
希望在自己的面板里就地放一块终端(典型是 dsh-docker 的终端抽屉:看着容器日志直接
进容器敲命令,上下文不断),用 mount:
ctx.inject(['ttyTerminal'], (c) => {
if (Number(c.ttyTerminal.version ?? 0) < 2) { /* 老版本:退回 open */ }
const dispose = c.ttyTerminal.mount(hostEl, {
command: "docker exec -it 'ems-consumer-test' sh", // 与 open 同一套 options
book: 'lab-a', // book > spec > 本地
label: 'ems-consumer-test · exec',
})
// 收起自己的抽屉时:
// dispose()
})
hostEl 需是 HTMLElement:tty 往里塞一个绝对定位的 .tt_term,所以挂载点要
position: relative 且有确定尺寸(尺寸变化会被 ResizeObserver 接住并同步给 PTY)。
- 嵌入终端与标签共用同一条 WebSocket 与会话表,但语义是「别人面板里的一块终端」:
不进标签栏、不写 sessionStorage、不参与 tty 面板的显隐;tty 面板关闭不会波及它
(反过来说:嵌入会话在跑时,tty 的连接不会被关掉)。
- 断线自动重连、宿主重启后按原命令重跑、退出后点击遮罩重开,全部沿用既有逻辑;
dispose() 结束会话并卸载 DOM。挂载是冷启动安全的——tty 面板没开、连接还没建,
mount 也会把连接拉起来(创建帧先排队,onopen 后补发)。
- 嵌入终端没有 tty 面板头部的搜索/清屏/复制工具栏,Ctrl+F 交还浏览器。
面板内挂载位(客户端服务 ttyPanel,0.16.0)
tty 自己的 SFTP 文件浏览也走这条通道(0.16.0):挂载位空着时挂在下方,
被同标签的别的面板占用时退回对话框。
mount 解决的是「消费方给宿主,tty 往里塞终端」;ttyPanel 是它的镜像——tty 在
终端面板里给消费方一块位置,让消费方把自己的界面挂进来。典型场景:dsh-docker 从 SSH
连接栏点「容器」,容器面板挂在终端右侧,终端继续可见、可点、可输入,而不是被整屏
弹窗盖住(0.15 之前那正是用户的痛点)。
挂载位是连接级的:它的凭证 / 目标来自打开它的那个终端标签。所以 0.19.0 起每块
pane 记一个「归属标签」(options.ownerSid,默认 = 挂载那一刻的活动标签):切到别的
标签时整块收起(data-dock-hidden,DOM 与你 render 的树都保活,在途传输继续跑),
切回来恢复现场;归属标签被关掉时 pane 一并收掉。不这么做的话,切完标签面板还停在原处
——标题写着 SFTP · lab-b、底下活动标签却是 192.0.2.10,面板里躺着另一台
主机的文件列表(最坏的情况是往错主机上传)。显式传 ownerSid: null 表示「不隶属任何
标签」(一直可见)——面板开着但一个标签都没有时自动落进这一档;消费方也可以用它声明
「这块与标签无关,别跟着切」。
ctx.inject(['ttyPanel'], (c) => {
// 面板没开(或 tty < 0.16)时走自己的弹窗
if (Number(c.ttyPanel.version ?? 0) < 1 || c.ttyPanel.isOpen() !== true) { /* fallback */ }
const pane = c.ttyPanel.mountPane({
title: 'Docker 容器', // 面板标题
hint: 'prod-web-01', // 标题右侧灰字(可选)
side: 'right', // 'right'(默认,竖向列表 / 列表+详情)| 'bottom'(横向宽表)
size: 520, // 初始尺寸 px:right = 宽度(默认 460)、bottom = 高度(默认 320)
min: 360, // 最小尺寸 px(可选,默认 280 / 160)
ownerSid: tab.sid, // 归属标签(可选):省略 = 当前活动标签,null = 不隶属任何标签
onClose: () => { /* tty 收掉面板时回调:在这里 unmount 自己的 React root */ },
})
createRoot(pane.element).render(<MyPanel />)
// 自己收起时:pane.dispose()
})
| 成员 | 说明 |
|---|
isOpen() | 终端面板是否正开着(最小化不算)。消费方据此决定「挂进来」还是「走自己的弹窗」 |
mountPane(options) | 在面板右侧 / 下方挂一块位置并返回 handle;同时只挂一个,后来的 mountPane 会先收掉前一个(并回调它的 onClose)。side:'bottom' 时占满宽度、由高度定尺寸(拖上边缘),折叠成一条标题栏。ownerSid 记归属标签(默认当前活动标签),切换标签时归属别的标签的 pane 会被收起(DOM 与你的 React 树保活,切回来恢复;见上文「连接级」那段) |
handle.element | 消费方 render 的宿主(flex 纵向、已 overflow:hidden,撑满正文区) |
handle.setTitle(text) / setHint(text) | 改标题 / 灰字 |
handle.expand() / collapse() / toggle() / isCollapsed() | 折叠成 32px 窄条(竖排标题 + 展开/关闭按钮),终端立刻拿回宽度 |
handle.dispose() | 消费方主动收起(幂等,不会再触发 onClose) |
- 生命周期:侧栏的 DOM 长在终端弹窗里——最小化 / 恢复跟着面板走,消费方不用管;
面板被关闭(✕ / 宿主卸载)时 tty 先调
onClose(消费方在这里 unmount),随后才摘 DOM。
- 拖尺寸:右侧 pane 拖左边缘、下方 pane 拖上边缘(上限 = 面板卡片长边的 72%,
给终端留位置),尺寸在同一次页面会话内按方向分别记住。终端区的尺寸变化由既有
ResizeObserver 接住,自动 refit 并把新行列数同步给 PTY。
- 视口锚定:pane 开合 / 拖尺寸会改变终端区高度,xterm 自己挪视口会让内容
「被往上顶」。refit 时按用户当时的意图锚定——本来贴着底部(在看最新输出)就
继续贴底,翻在历史里就锁住原来那几行,不会跳走。
- 方向怎么挑:竖向列表 / 列表+详情(如容器面板)用
right;横向宽表(如 SFTP
文件列表、本地↔远程双栏)用 bottom —— 全宽摆得下更多列,也不挤终端宽度。
- 标题栏(标题 / 折叠 / ✕)由 tty 提供,消费方只管自己的正文;
onClose 抛错只记
console.warn,不影响面板关闭。
minimize()(契约 v2) 把整个终端面板折进去:弹窗藏起来,但 DOM / WebSocket / xterm
缓冲全保留、会话继续跑;恢复靠侧边栏「终端」入口上的徽标。消费方用它「把舞台让出去」
——典型是 dsh-docker 把日志交给会话之后自动折起终端,让用户直接看到会话,而不是对着一个
盖住会话的弹窗猜「点了没有反应」。返回值是调用后的最小化态,调用方据此决定提示文案里还要
不要写「会话在面板后面」。
ctx.inject(['ttyPanel'], (c) => {
if (Number(c.ttyPanel.version ?? 0) < 2 || typeof c.ttyPanel.minimize !== 'function') return false
if (c.ttyPanel.isOpen() !== true) return false
return c.ttyPanel.minimize() // 折起终端,让会话露出来
})
契约版本:ttyConnbar.version === 1、ttyTerminal.version === 3(1 = 只有 open,
2 = 增加 mount,3 = open 默认复用同「连接 + 命令」的活标签)、ttyPanel.version === 2(1 = mountPane + isOpen,2 = 增加
minimize)。消费方按版本号判断能力,不要用
typeof fn === 'function' 之外的假设;老版本 tty 上 inject 依然会触发,但没有
对应字段。
帧协议(/api/dsh-tty/ws,JSON 文本帧;v3 = 单连接多会话 + 断线重连)
| 方向 | 帧 | 说明 |
|---|
| C→S | {t:'spawn', sid?, cols?, rows?, cwd?, persist?, persistName?, command?} | 创建会话;sid 缺省由宿主生成,cwd 缺省用配置兜底;persist + 稳定 persistName(0.10.0)= tmux 持久会话(dsh-<名>,需 persistence=tmux);command(0.14.0)= 直接跑一条命令(不做持久化) |
| C→S | {t:'ssh', sid?, cols?, rows?, name? | host, username, …, persist?, persistName?} | 创建 SSH 会话(ssh2 原生);name 引用连接簿条目作基底,内联 host/port/username/auth/keyPath/passphrase/password/agentForward 可逐项覆盖;persist 语义同 spawn(远程 tmux 托管) |
| C→S | {t:'input', sid?, d} | 按键/粘贴数据 |
| C→S | {t:'resize', sid?, cols, rows} | 面板尺寸变化 |
| C→S | {t:'refresh', sid?} | 强制重画(0.10.1):宿主对 tmux 会话执行 refresh-client(客户端 reset 清残 scrollback 后请现场重画;非 tmux 会话 no-op) |
| C→S | {t:'kill', sid?} | 关闭会话(孤儿会话也允许跨连接 kill,防泄漏) |
| C→S | {t:'sessions'} | 列出全局会话快照(attachable 标记可重连者) |
| C→S | {t:'attach', sid} | 重连孤儿会话(保活窗口内):ready(reattached:true) 后紧跟一帧 data 回放输出缓冲 |
| C→S | {t:'statsOn' | 'statsOff', sid} | 订阅/退订该会话的服务器状态条(0.17.0):按标签可见性驱动,宿主只在有订阅时采集(懒启动 + 退订即停表并关远端 channel) |
| S→C | {t:'ready', sid, pid, kind, target?, persist?, reattached?} | 会话就绪;kind:'local' 带 pid,kind:'ssh' 时 pid=null、target=user@host[:port];attach 复用此帧并带 reattached:true;persist:true 表示 tmux 持久会话(0.10.0) |
| S→C | {t:'data', sid, d} | 终端输出(utf8 文本,StringDecoder 兜跨帧多字节序列);12ms 窗口/64KB 阈值合并成帧(0.4.1),exit/kill 前强制冲刷保证帧序 |
| S→C | {t:'stats', sid, stats} | 资源指标帧(0.17.0):{cpuPct, cores, memUsed, memTotal, memPct, diskUsed, diskTotal, diskPct, uptimeSec, tcpConns, rxRate, txRate, tempC?};缺失字段即省略(best-effort,前端显示「无」),字节类为 bytes、速率为 B/s |
| S→C | {t:'exit', sid, code, signal} |
断线保活语义:客户端正常关面板会先逐个发 kill 再断开;因此「WS close
且仍有存活会话」判定为异常断开——会话转入孤儿状态(输出继续积累进环形
缓冲,不向任何连接发送),保活 reconnectGraceSec 后由回收器清理;期间
新连接可 {t:'sessions'} 查询 + {t:'attach', sid} 重连回放。
sid 省略时按「该连接唯一会话」路由;连接上存在 0 或多个会话时省略 sid
会报错。upgrade 路由带 loopback 信任围栏(remoteAddress + Host + Origin
校验),仅本机 Web GUI 可连。
开发
pnpm --filter @hyzyn/dsh-tty build # tsc 宿主 + esbuild 浏览器半体(client.js)
pnpm --filter @hyzyn/dsh-tty typecheck
pnpm --filter @hyzyn/dsh-tty probe # M0 探针:PTY 原语验证(需真实 PTY)
pnpm --filter @hyzyn/dsh-tty integration # 集成测试:真实插件 × 真实 DSH 服务组合
pnpm --filter @hyzyn/dsh-tty live # 存活冒烟:需先起 dsh web(默认连 ws://127.0.0.1:3080,DSH_TTY_WS_URL 可覆盖)
pnpm --filter @hyzyn/dsh-tty tui # TUI 冒烟:vim/htop 全屏渲染(需先起 dsh web,默认连 :3090,DSH_TTY_WS_URL 可覆盖)
pnpm --filter @hyzyn/dsh-tty ssh-smoke # SSH 冒烟:内存 SSH server(ssh2.Server)× 真实 spawnSsh 端到端(需先 build)
pnpm --filter @hyzyn/dsh-tty probe-smoke # 试连分类与 TOFU(7 例,自包含)
pnpm --filter @hyzyn/dsh-tty probe-route-smoke # 试连 HTTP 路由 + 连接簿(9 例)
pnpm --filter @hyzyn/dsh-tty sftplimits-smoke # sftpLimits 配置归一化(6 例)
pnpm --filter @hyzyn/dsh-tty windows-smoke # Windows 端到端(只在 Windows 上有意义,其他平台打印原因后跳过)
CI(`.github/workflows/ci.yml`)在 ubuntu 跑 `integration` + `ssh-smoke` + 三个 smoke,在
windows-latest 跑 `windows-smoke`,另有一道「构建产物与源码一致」闸门(`client.js` + `lib/`)。
0.19.0 的 48 项修复与审计索引见 [`DEFECTS.md`](./DEFECTS.md)。
pnpm --filter @hyzyn/dsh-tty preview # 视觉预览:headless Chrome 逐场景截图(见下)
浏览器半体源码在 client-src/index.js,样式表独立成 client-src/tty.css(由
esbuild 的 text loader 内联进 client.js)。构建产物 client.js(含 xterm 内核),
改客户端后需重新 pnpm build 并刷新页面(可能需硬刷新)。
视觉预览 / 截图回归(scripts/preview.mjs)
改样式不该靠「刷新页面看一眼」:脚本把 client.js 装进一个纯静态夹具页
(scripts/preview/harness.html + mock-host.js 伪造的 DSH 宿主:module
loader / fetch / WebSocket),用 headless Chrome 把 29 个界面状态逐个渲染并
截图到 packages/tty/.preview/shots/:
node scripts/preview.mjs # 全场景
node scripts/preview.mjs local menu ssh # 指定场景
node scripts/preview.mjs --list # 列出场景
node scripts/preview.mjs --theme=light # 浅色主题
覆盖:本地终端 / 多标签 + SSH 连接栏 / 「+」菜单 / SSH 对话框(新建、编辑、试连)/
设置卡片(含与 docker 并排对照)/ SFTP(单窗体、双栏、落点回退)/
挂载位跟着标签切(dock-pane-tab,0.19.0 的回归)/ 最小化徽标 / 退出与错误遮罩 /
隧道弹层 / 搜索框 / toast / 嵌入式终端(单独与面板共存)/ docker 面板与「容器 → 终端抽屉」。
场景可以把函数形态的断言挂到 window.__previewAssert(返回 null = 通过,返回
字符串 / 数组 = 失败),脚本会跑掉它并把结果计入 ✓/✗。断言光挂在夹具里、只有手工
取 diag 时才有人看,回归等于没钉——0.19.0 修「面板不跟标签切」时就吃到这个亏:断言
早写好了,但因为混在带函数的 diag 对象里,整条求值静默失败,一路都是 ✓。
夹具还会把 --dsw-* 皮肤变量与真实界面一并渲染,因此能验
「明暗主题切换后是否还有白色面板」这类问题。产物目录 .preview/ 已 gitignore。
夹具里的 ctx.inject 与真实 cordis 同语义:依赖里有一个服务不存在就不触发回调,
而不是把 undefined 塞进 scope。塞 undefined 的后果是一个可选依赖(例如 dsh-docker
那条 sidebarRight/sidebarRightTabs 支路)就能让所有场景在挂载阶段炸掉
(Cannot read properties of undefined (reading 'register'))——夹具不提供宿主侧的
侧栏服务时,那一支就该安静地不注册。
夹具需要 Chrome/Chromium(默认找 playwright 缓存的 Chrome for Testing,
也可用 CHROME_PATH 指定)。若宿主环境限制了 Chrome 的沙箱(子进程被
拒),需要放开后运行,否则浏览器起不来。
已知限制
- resize 为内部耦合:DSH 的
spawnTerminal handle 未暴露 resize,
插件直接透传 (handle).terminal.resize(cols, rows)(node-pty 原生 API,
同进程可达)。DSH 升级若改内部结构,0.3.0 起会警告一次并退化为固定尺寸,
不再逐帧抛错。
- TERM 注入用
-c 包装层(仅 POSIX):DSH 硬编码 node-pty name:"dumb",而
node-pty 里 name 优先于 env.TERM,因此 shell 以
sh -c 'export TERM=...; exec "$shell"' 方式启动(对用户透明;TERM /
COLORTERM 值做白名单校验,防止破坏包装层命令)。Windows 上没有这一层 —— cmd /
PowerShell 都不认这套语法,ConPTY 也不需要 TERM(见「Windows 宿主」一节)。
- Windows 宿主:本地终端可用(默认
%COMSPEC%),但 shell 集成 / tmux 持久化 /
磁盘·TCP·网速·温度这几项不适用或拿不到 —— 完整边界与验证范围见「Windows 宿主」一节。
- terminate() 有「幸存者」竞态:DSH 树级清理偶发报
terminal cleanup failed; surviving pids,插件按 best-effort 处理
(失败降级对顶层 shell 直接 SIGKILL),退出码/信号可能为 null。
- 输出为 utf8 文本流:node-pty 数据经 DSH 按 utf8 编码传输,非 UTF-8
字节会被替换字符吃掉(如
cat 二进制文件),属预期行为;跨 chunk 的
多字节 UTF-8 序列已由 StringDecoder 兜住(0.3.0),中文高速输出不再花。
- 浏览器半体依赖官方
dsh-web-app 的侧边栏结构([data-pane="sidebar"]),
非官方 Web GUI 可能不显示入口。
- 断线保活窗口有限:异常断开后会话仅在
reconnectGraceSec(默认 120s)
内保活可重连,宿主进程重启则所有会话结束;超期后未重连的会话由回收器
结束,输出缓冲(尾部 256KB)之外的滚动历史无法恢复。
- shell 集成仅 zsh / bash:其他 shell 自动跳过(
tty_capture{last} 会
明确报错而非错报)。bash < 4.4 走 DEBUG trap 兜底:循环体等复合命令的
内部命令会多发 B 标记,tty_capture{last} 对这类命令只截取最后一个内部
命令之后的输出(退出码与 tty_expect 不受影响)。用户 rc 若覆盖
PROMPT_COMMAND/钩子数组,集成可能失效——可关闭 shellIntegration 或
反馈补丁兼容。
- 端口转发边界:本地监听固定 127.0.0.1(不暴露局域网);remote 方向服务端监听还受服务端 sshd
GatewayPorts 限制;隧道的 SSH 连接与终端会话独立,均走 TOFU 钉扎与连接簿认证;隧道规格变更(端口/目标/启停)经「保存」热生效,热改连接簿凭证则在下次重连时生效。
- 会话持久化(tmux)边界:持久标签由 tmux server(专用 socket
dsh-tty)托管——宿主被硬杀 / 保活回收 / 浏览器丢失标签规格时,tmux 会话会留存(这正是恢复能力的前提),直到机器重启或手动 tmux -L dsh-tty kill-server;agent 命令粒度工具(capture{last}/expect)依赖 tmux ≥3.3 的 DCS allow-passthrough,更低版本持久化可用但该能力降级(SSH 远程会话不注入 shell 集成钩子,capture{last} 本就不可用,与持久化无关);恢复接回重画的是当前可见屏,断线前的滚动历史在 tmux 自己的 history buffer(copy-mode)里,不在外层 xterm 滚动区;持久标签的 exit 帧退出码是 tmux 客户端的(0),shell 退出码经 OSC 133;D 标记照常可用;tmux.conf 只在 tmux server 首启时读取(改配置后需 tmux -L dsh-tty kill-server 让下次 spawn 重建 server);grace=0 的「断开立即结束」对持久标签同样会 kill-session(tmux 会话不存活);持久 SSH 会话要求远程装有 tmux(未装自动降级普通会话,连接栏常驻「未持久化」标记),且远程 ~/.tmux.conf 不影响专用 socket 的独立 conf(-f /dev/null);SSH 持久会话名随 settings 留存;时共享同一个宿主 PTY(0.10.1,单客户端扇出,名额不翻倍),两侧行数以最近调整尺寸的窗口为准(尺寸不一致时较大一侧由 onResize 自愈重画)。
工作原理
浏览器半体 (client.js, esbuild bundle)
├─ 侧边栏「终端」入口 → 大弹窗
├─ 标签栏:每标签一个 xterm.js 实例(独立 sid;WebGL 渲染器,丢失回退 DOM)
├─ 断线重连:指数退避自动重连 + sessions 查询 + attach 恢复;
│ 标签列表存 sessionStorage(刷新后按 sid 重连保活会话)
├─ sessions 客户端服务:新标签带当前会话 cwd
└─ WebSocket ──→ /api/dsh-tty/ws (webServer.registerUpgrade)
│
宿主半体 (src/index.ts)
├─ 连接内会话表(sid → 本地 PTY / SSH channel,单连接多会话)
├─ SessionManager(maxSessions 上限,热调整;SSH 会话同表调度;
│ 孤儿回收器按 reconnectGraceSec 清理异常断开的会话)
├─ 每会话 256KB 环形缓冲(tty_capture / 断线回放)+ xterm-headless
│ 虚拟屏(tty_screen)+ StringDecoder(utf8 分帧兜底)
├─ shell 集成(src/shell-integration.ts):zsh ZDOTDIR / bash --rcfile
│ 桩注入 OSC 133/7 钩子;输出流解析(feedShellIntegration,跨 chunk
│ 残包 carry)→ 命令边界捕获(tty_capture{last} / tty_expect 早停)
│ 与 cwd 跟随(tty_list)
├─ 本地路径:ctx.get('subprocess').spawnTerminal({ argv: shell -c 包装层, cwd })
│ 持久标签(0.10.0):包装层换 `exec tmux -L dsh-tty -f <conf> new -A -s dsh-<名>`
│ (src/tmux.ts:探测/资产生成/spawn 计划/kill-session;稳定资产在
│ <DSH_HOME|~/.dsh>/tty/——tmux.conf + inner.sh + zsh/bash 桩;tmux 会吞
│ 不认识的序列,shell 集成钩子检测 $TMUX 把 OSC 133/7 包 DCS passthrough
│ 信封,tmux ≥3.3 解包转发,宿主解析器零改动)
├─ 辅助路由:/api/dsh-tty/ssh-config(~/.ssh/config 导入候选)、
│ /api/dsh-tty/credential-refs(凭据存储里已知的引用名 —— 只要名字,见「凭据存储」)、
│ /api/dsh-tty/env-vars(env 插件托管变量名)、/api/dsh-tty/known-hosts
│ (TOFU 指纹预填充,src/known-hosts.ts 解析含 hashed 条目)、
│ /api/dsh-tty/shells(Shell 路径候选)——均 loopback 围栏
├─ SFTP(src/sftp.ts,0.7.0):懒连接池(空闲 120s 回收、断开按需重连、
│ TOFU 共用)→ POST /api/dsh-tty/sftp/list|mkdir|rename|remove|download|
│ upload(spec 走体/头,凭证不进 URL;上传下载流式 pipe)+
│ sftp_list/read/write/mkdir/rename/remove/tree 工具(只收连接簿名;
│ mkdir 支持 parents 自底向上逐级补齐,tree 限深限数递归列举)
├─ 端口转发(src/tunnels.ts):宿主自持隧道(-L/-R 双向),断线退避重连、
│ TOFU 共用、连接计数;GET /api/dsh-tty/tunnels 实时状态 + tunnel_list 工具
└─ 帧协议:spawn|ssh / input / resize / kill / sessions / attach
↔ ready/data/exit/error/sessions + 背压
SSH 路径 (src/ssh.ts)
└─ {t:'ssh'} → spawnSsh:ssh2 Client 原生连接(agent / key / password,
password·passphrase 支持 env:VAR 取密;host key 经 HostKeyStore
TOFU 钉扎),开 shell channel 包装成与 PTY 同形状的 TermHandle
(pid=null,kind='ssh',target=user@host[:port]),
背压一并透传到 channel —— 之后与本地 PTY 无差别调度;
persist 时先 `command -v tmux` 探测再 `exec tmux -L dsh-tty -f /dev/null
new -A -s dsh-<名>` 开 pty channel(远程 tmux 托管;无 tmux 降级普通
shell channel + 灰字提示;kill 经连接内 `tmux kill-session` 收尾)
M0 探针、集成测试(B1B24 共 58 项断言)与真实实例冒烟(live / TUI)在
真实 DSH 服务组合上验证过:TERM 注入、resize 透传、sid 冲突、并发上限、
loopback 围栏、多会话数据隔离、cwd 跟随与校验、配置热生效(settings/updated)、
kill→exit 全链路、断线保活 + sessions/attach 重连回放、tty_screen 虚拟屏、
tty_capture ANSI 清洗、shell 集成(capture{last} + exitCode、OSC 7 cwd
跟随)、tty_expect 匹配与超时、/.ssh/config 解析器、端口转发(双隧道 active + forwardOut 往返 + reconcile 清理)、
shells 候选路由、bash 3.2 shell 集成(DEBUG trap 兜底:capture{last} +
exitCode、tty_expect 命令结束早停)、SFTP 文件传输(list/mkdir/upload/
download/remove 路由 + sftp_* 工具,test-sshd 内存 sshd 端到端)、SFTP 管理
闭环(agent sftp_mkdir/-rename/-remove/-tree:parents 补齐、tree 限深截断、
跨目录移动、非空删除拒绝与递归删除);会话持久化(B25/B26:persistence
门控、tmux 会话托管与 kill-session 收尾、无 tmux 降级提示、DCS passthrough
下 capture{last}、保活回收只杀 PTY 不杀 tmux 会话、同 persistName 接回现场
——本机无 tmux 时自动只跑降级路径)。SSH 路径由 ssh-smoke
(内存 SSH server × 真实 spawnSsh)验证:password 认证建链与 prompt、
命令往返、pty-req 初始尺寸与 resize(window-change)、terminate /
exit-status 全链路,以及 TOFU 指纹记录(S7)与指纹变更拒绝连接(S8);
SFTP 由 ssh-smoke S9(test-sshd 的 sftp subsystem × 真实 SftpManager)覆盖:
目录列表与 realpath home、上传覆盖+追加、下载(stat size 作 content-length)、
mkdir/rename/非递归删除拒绝/递归删除、TOFU 指纹变更拒绝;S9g 覆盖 mkdir
parents 自底向上逐级补齐(幂等)与 tree 限深截断/全量。