DeepSeek Harness 插件

hyzyn/dsh-plugin-kit#tty

Star 数 ★ 48 下载量(近 30 天) 6,969 分类 UI 增强 收录于 2026-08-30 npm @hyzyn/dsh-tty

DSH Web GUI 的终端面板:侧边栏「终端」打开多标签页 xterm.js 全交互终端(node-pty 真实 PTY),支持 ssh2 原生 SSH 远程标签页(agent/key/password 三种认证),cwd 跟随当前会话,并提供 tty_list / tty_capture / tty_send 三个 agent 工具。

安装

# npm 包(预构建)

dsh plugin --profile web add @hyzyn/dsh-tty

# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)

dsh plugin --profile web add github:hyzyn/dsh-plugin-kit#path:/packages/tty

装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。

README

中文 | 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 拿到「上一条命令」的输出与退出码,而不是抓屏猜;tty_list 直接报有没有命令在跑(running 三态:在跑 / 已结束 / 无法判断),tty_run 一条命令一次调用拿回输出与退出码。
  • 对其它插件开放两个客户端服务:ttyConnbar(连接栏动作)与 ttyTerminal(就地开终端);dsh-docker 的「容器 / 终端」按钮即走这两个扩展点。
  • AI 辅助「失败即解释」(0.24.0,默认关):命令非零退出时右下角浮出徽标,点它才把那条命令的输出尾部(清洗 / 截断 / 密钥遮盖后)连同当前屏幕发给模型,换回「发生了什么 / 下一步」;模型给的命令只填入命令行、永不替你回车。见 AI 辅助。

安装

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);标签栏放不下时横向滚动 (无滚动条,滚轮 / 触控板横扫,两端渐隐提示),并在标签区右缘出现 「⋯」标签列表:只列被挤出视野的那些标签(状态点 + 目标名; 当前标签若在其中则高亮),点一行即切换、行内 ✕ 直接关闭 (关完列表不关,可连着关几个);
  • 一次关掉一批标签(0.30.0):面板里攒了几个标签不必再一个个点 ✕—— 右键任一标签即以它为参照弹菜单(关闭此标签 / 关闭其他标签(N)/ 关闭左侧标签(N)/ 关闭右侧标签(N)/ 清理已退出的标签(N);目标为空的行不出现——参照在头上就没有「左侧」, 在末尾就没有「右侧」),中键直接关掉指向的那个; 「⋯」里挂着同一份批量行(参照 = 当前活动标签),且从 0.30.0 起它标签 ≥ 2 个就在 (不再要求溢出或有死标签)——右键是快路径,但不该是唯一出路;只剩一个标签时照旧藏起来 (没有可管理的对象,那才是噪音)。会结束 ≥2 条活会话的动作先弹一次确认(写明几条、 其中几条是 AI 开的),只关一条活会话与点它自己的 ✕ 等价、不弹(判据在 client-src/tab-bulk.js,与关面板的 D98 同思路:按「会损失什么」判);
  • 标签「对 AI 不可见」(0.30.0,默认关闭):右键菜单多一项「对 AI 隐藏此标签」—— 藏起来之后,agent 侧完全看不到它:tty_list 与每轮 systemPrompt 的会话块里都没有它 (连 cwd、有没有命令在跑都不给,只报一句「另有 N 条被用户设为对 agent 不可见」), tty_capture / tty_screen / tty_send / tty_expect / tty_stats 按 sid 取它一律被拒; 标记是标签标题前的一个 🙈 与 tooltip(「⋯」列表里同样显示)。在你自己的面板里它照旧完全 可用(能看能敲能关),拦截只在宿主侧对 agent 生效——留一条自己的终端跑密钥、私事或与 本轮无关的活,不必担心被读屏。AI 自己开的标签不给这一项(藏起来等于把它的行为从你眼前 抹掉);判据在 client-src/tab-hidden.js + 宿主 setHiddenFromAgent,重连后只重报「已隐藏」 (不会把隐藏状态悄悄解掉);
  • 工作目录跟随当前 DSH 会话:新标签默认在当前会话工作目录打开 (宿主配置 cwd 作兜底)。0.1.6 起会话列表快照不再带 current(视图选中项搬到了 workspace 域),客户端改看 retainedBy.mainView > 0 认当前会话——只认老字段会让 cwd 恒为空、新标签回落宿主启动目录;
  • 支持 vim / htop / less 等 TUI(TERM 已注入为 xterm-256color);
  • 面板大小变化自动 resize(xterm fit → PTY 原生 resize);非活动标签不跟手——它保留自己 最后一次有效尺寸(D79:容器不可见、或探测结果退化时,尺寸帧一律不发);
  • Ctrl+F 终端内搜索(Enter 下一个 / Shift+Enter 上一个 / Esc 只关搜索框), 输出中的链接可点击;头部工具栏只留 搜索 / 清屏 两颗——复制 / 粘贴不占按钮位, 用终端自己的快捷键(有选中时 Cmd+C / Ctrl+Shift+C 复制、Cmd+V / Ctrl+V 粘贴);
  • 断线自动重连(0.3.0):刷新页面、网络抖动等异常断开后,会话在宿主 保活 reconnectGraceSec(默认 120 秒),客户端指数退避自动重连(封顶 5s), 重连后按 sid attach 回原会话并回放断线期间的输出缓冲;页面刷新后从 sessionStorage 恢复标签列表(宿主侧已结束的会话自动丢弃);
  • WebGL 渲染器(0.3.0):高吞吐输出(build 日志)渲染性能质变;WebGL 上下文丢失(多标签超出浏览器配额等)时自动回退 DOM 渲染器;
  • 最小化(状态并入侧边栏入口):点弹窗外空白处、按 Esc 或标题栏「—」 把面板收起——PTY 会话与输出缓冲保持存活,侧边栏「终端」入口上显示 「运行中/总数」徽标与状态点(有输出时脉冲提示),点击入口即可恢复; 悬浮条 ✕ / 标题栏 ✕ 才真正关闭并结束全部会话;
  • 关面板会先问一句(issue #8):面板里还有活着的会话时,点 ✕(含悬浮条上那颗) 先弹确认——写明这一下会结束几条会话、其中几条是 AI 开的,并把「最小化」(会话与输出 照常运行)放进同一行按钮(此前只能按 Esc / 点空白取消,再去点标题栏的「—」,而确认层 盖住整个视口、那一刻它根本点不到;把它放到手边,误点就直接变成正解);只剩已退出的标签 或空面板时直接关,不加打扰(判据是「会损失什么」,不是「开了几个标签」:确认弹成习惯之后 真正的拦阻就失效了);
  • 已退出的会话不再遮住输出(issue #7①②):退出态改成终端区顶部一条提示条 (一行「已退出 code=…」/「AI 已结束这个会话」+ 最多一颗动作按钮),输出照常可读、 可滚、可选中复制。此前那层铺满终端区的遮罩既是「AI 跑出来的结果看不清」的原因 (用户得去 F12 把 .tt_overlay 藏掉),也整块吃掉点击——连复制都做不到。错误态与 「重连中」的遮罩照旧保留:那两层的用处是解释「为什么这里没有输出」;
  • 「⋯」里的「清理已退出的标签(N)」(issue #7③):一键收掉面板里所有已退出的标签 (顺手让宿主立刻释放它们的只读保留);没溢出但有死标签时「⋯」也会出现——此前那个 按钮只在标签栏溢出时才露头,而 agent 每跑一条命令就留一个死标签,用户攒到 47 个只能 一个一个点 ✕;
  • AI 开的标签跟会话走(issue #7④):AI 用 tty_close(以及 tty_run 的默认收尾) 释放会话后,对应标签跟着关——你自己切过去正看着的那一个会先留一台阶(提示条写「AI 已结束 这个会话」+「收起」,切走或点它再收;客户端自己顶上去的活动标签不算);仍在只读保留里的会话(tty_run keep:true、 tty_open command= 跑完没被 close)标签留着、输出可读,由上面那条一键清理收;
  • 标题栏 ✕ 关闭面板并结束全部会话(PTY 树级清理;tmux 持久标签同时 kill-session);会话退出后从顶部提示条重开(本地标签按原规格新开一条、 持久标签接回 tmux、命令标签重跑一次);
  • 并发上限默认 4(配置 maxSessions,1~16)。

Windows 宿主(0.18.1,best-effort)

0.18.1 之前,Windows 宿主上的本地终端一条都开不起来,两处都在同一条路径上:

  1. $SHELL 在 Windows 上根本不存在,默认 shell 无条件回落 /bin/zsh → spawn 直接 ENOENT(实测:spawn /bin/zsh -> ENOENT,而 %COMSPEC% 可用);
  2. 启动计划是 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;PowerShell 的 -Command 会把「末条命令非零」塌成进程退出码 1 (cmd /c exit 5 → 1,两版一致),要拿到具体码值就让命令自己 exit 5(POSIX 侧 sh -c 不会这样);
  • 输入行尾归一化(D74,2026-09-27 真机报告):conhost 的 Enter 是 CR,裸 LF 只把光标 下移一格、不提交命令行——tty_send 按工具描述发 echo X\n 时命令停在输入行上(没有输出、 没有新提示符,看起来像「发出去了但没执行」)。win32 的本地会话上插件现在把裸 LF 补成 CRLF (已经是 CRLF 的不重复补;非 Windows 与 SSH 会话原样透传),所以照描述写 \n 这条主路径直接可用;
  • shell 集成(OSC 133/7)按家族分:PowerShell 家族支持(powershell.exe / pwsh.exe)—— 注入走 -File 稳定桩(挂 prompt + PSConsoleHostReadLine 钩子),拿到 tty_list.cwd 跟随 cd、退出码、running 三态与命令粒度的 tty_expect;cmd 不支持(没有命令开始/结束钩子), 设置卡片把开关置灰并指向「把 Shell 路径换成 PowerShell」。两版语法不同,提示文案按版本分支给: && / || 只有 PowerShell 7 认,5.1 上写 && 是解析错误(一条命令都不会跑)。 已知限制(未复现):曾有记录说 PowerShell 7 上命令输出在 ConPTY 里晚于 D 标记抵达、 tty_capture{last} 可能取到空;2026-10-13 在 pwsh 7.6.6(Win11 ARM64)上按三档输出量复测 (20 次小输出 / 2000 行大输出 / 2s 内分 20 次的慢输出)0/20 空窗,未复现 —— 真遇到就用 tty_capture 读尾部,并把当时的 pwsh 版本报回来。另有一条退出码口径:PowerShell 家族里 解析错误(5.1 上的 &&、两版上的 echo (1)命令一条都没跑,而 D 标记报 0 —— 不能只看 退出码判成功,报错文本在输出里。 桩靠 -ExecutionPolicy Bypass(进程级)才载得进来,两条边界如实记着:组策略把执行策略锁成 Restricted / AllSigned 时该参数压不过 GPO,桩载不进去、标签照开而集成静默失效(与「用户关了 PSReadLine」同一种降级);这个参数还会把值写进 $Env:PSExecutionPolicyPreference,从该会话里 再起的 PowerShell 会继承它。另有一条已知副作用:包装 prompt 会让用户原 prompt 读到的 $? 变成我们那几次写入的成败(恒真),依赖 $? 的失败提示会失效;
  • 不支持的两项(宿主侧自动关掉,设置卡片里写明原因):
    • 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 → 重开,外加两条只在真机上成立的链路(W6:tty_send 的裸 LF 经归一化 后 conhost 真的执行了命令,D74;W7:command 型会话跑完退出后转只读保留、tty_capture 仍读得到 退出前的输出,D77)。W8(PowerShell 的 OSC 133/7 档)在 CI 上照常跑(2026-10-13 起): 它此前只在真机车道守,是因为「杀一个 ConPTY 会话」会踩 0xC0000374——根因(同一个 PTY 被原生 kill() 两次)已定位并修好,见 DEFECTS.md 的 D100 与 ROADMAP.md 的 2026-10-13 一节;本机找不到 PowerShell 时它会如实进 SKIP 一档 (分母扣掉、汇总行点名,不许装成 12/12)。它抓出并钉住了「强杀本地 PTY 会崩宿主」这类只在 Windows 出现的问题——node-pty 在 Windows 上不接受 signal,而它的 _deferNoArgs 会把这个异常推迟到 socket 回调里抛出,调用方的 try/catch 拦不住。

agent 工具

插件向 agent 注入十九个工具(操作实时显示在用户终端里;每次调用受会话权限档位约束,见下节):

工具 作用
tty_list 列出终端会话(sid / kind(local|ssh)/ target / pid / cwd 实时跟随 cd / 活动时间;tmux 持久会话带 persist 标记;agent 自己开的带 owner: 'agent')。含进程已退出但仍在只读保留期内的会话(exited:true + 退出码/信号,见下节);running 说明这个会话有没有命令在跑(true = 在跑,现在别往里发命令;false = 命令已结束;省略 = 无法判断——非持久 SSH / fish·csh / Windows 的 cmd 没有 shell 集成标记,不是「没在跑」),同源的 lastExitCode / lastExitAt 是「上一条已完成命令」的退出码与结束时刻(0.23.0)。文本里三态写作 [空闲] / [运行中——现在别往里发命令] / [命令状态未知——…];「未知」还包含一种情形:宿主本次启动之后没见过该会话的标记(典型是 tmux 持久会话在宿主重启前就停在提示符上)——对它发一个回车或跑一条命令,标记补上就变 [空闲];而非持久 SSH / fish·csh / Windows 的 cmd 永远不会有标记,[命令状态未知] 是它们的常态
tty_open 自己开一个终端会话(0.20.0):本地 shell,或 command 直接跑一条命令(dev server / watch;按宿主 shell 的语法整段执行——POSIX shell 上 cd x && cmd、a; b、多行脚本都可以;Windows 的 cmd / PowerShell 则按它们自己的语法,当前 shell 与语法每轮都写在 systemPrompt 的终端一行里;不受下面「命令标签单行 ≤2000」那条客户端约束,D78),persistName 可要 tmux 持久化。开出来的会话出现在用户的终端面板里(普通标签、用户可见可接管),不做隐形会话
tty_close 关掉一个由 tty_open 开的会话(0.20.0)。只允许关 agent 自己开的:用户在面板里开的标签会被拒绝——agent 不越权结束用户正在用的终端。对已退出但仍只读保留着的会话同样可用——那是它的释放入口(默认保留到显式关闭,不按时间释放)
tty_run 一次性命令(0.23.0):开一个会话跑 command,等它结束,直接返回尾部输出 + 退出码——把「tty_open → 等 → tty_capture{last} → tty_close」四步压成一次调用。默认跑完即关(结果已在本次调用返回),keep:true 则留在只读保留态;到 timeoutSec(1~600,默认 120)还没结束就返回 running:true 且不杀会话(继续等或关掉由调用方决定)。何时用它而不是 bash 工具:需要用户看得见这条命令、或要跑在终端会话里时用它,纯非交互命令用 bash 更直接
tty_stats 读会话所在机器的实时指标(0.20.0):CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长。本地会话取宿主机;SSH 会话取那台远程主机(另开一段非 PTY 通道,不影响终端)。部署、压测前先看它
tty_capture 读取近期输出(尾部 N 行,默认清洗 ANSI,raw:true 取原始流);last:true 只返回上一条已完成命令的输出 + 退出码(shell 集成标记,见下节);命令在途时(刚发送、完成标记未到)返回 inProgress:true 且不带旧结果——避免把上一条的输出当成这一条(0.19.0)。命令型会话(tty_open command= / tty_run)没有「上一条命令」(它们不注入 shell 集成钩子),对它们用 last:true 会明确报错并指向 lines(0.23.0,D87)
tty_screen 读取当前可见屏幕的渲染结果(xterm-headless 虚拟屏,纯文本)——能真正读懂 vim / htop / 菜单等 TUI 界面
tty_expect 用正则等一个就绪信号(dev server URL、构建完成等)。先回溯还没被读过的输出(含「上一条命令」的完整输出,故命令瞬间跑完也不会白等),再等后续输出;命中即返回 matched:true + matchedFrom(live 本次等待期间新产生 / last 上一条命令的输出 / buffered 此前已到达的缓冲输出)。超时不抛错(matched:false + 尾部输出,且不消耗未读区——换个 pattern 还能回溯到同一段),命令提前结束也会带退出码早停;刚发进去的命令回显不算命中(靠 OSC 133 的 A..B 边界,无标记环境靠发送记录剔除,见下节;只命中回显时超时结果带 echoOnly:true);同一会话在途调用最多 5 个,累积窗口只保留尾部 64KB(0.19.0;回溯匹配见 D72,回显剔除见 D75)
tty_send 向指定会话发送文本与/或按键(如 dev server 的 q 键、菜单选择、vim 的 :wq)。控制键与方向键用具名 keys(["C-c"]、["Down","Down","Enter"],白名单查表;名字不认得就报错,绝不静默当字面量发出去),不要自己在 data 里拼转义序列——"\x1b[B" / "^[[B" 会被当成普通字符打印进终端,而 sent 计数看不出区别(0.23.0);data 与 keys 至少给一个,两个都给时先 data、再按序 keys。命令以 \n 结尾即可:Windows 本地会话上行尾裸 LF 会被归一成 CRLF(D74,见「Windows 宿主」一节),非 Windows 与 SSH 会话原样透传。对已退出的会话会明确报错(那种会话只能读)
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 列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、连接数);fatal:true = 人工介入级故障(本地监听失败 / 连接簿缺失),不会自动重试,修配置后重建
tunnel_start / tunnel_stop 启停一条端口转发隧道(0.25.0,name 来自 tunnel_list)。会改写配置——与在设置卡片里勾选/取消那个「启用」是同一件事(写回 settings → 热应用 → reconcile),所以是持久的、用户在卡片上看得见。返回值是操作后的状态快照:state 通常还是 connecting(拨号异步),要最终结论稍后 tunnel_list

会话权限档位闸(tier gate)

工具按风险分三类,每次调用按会话的权限档位(仅可查看 / 工作区内修改 / 完全权限 / Auto review) 决定放行、询问还是拒绝——档位是用户在会话里切的,切完下一个调用立即按新档走:

类 工具 受限档(仅可查看 / 工作区内修改)下的行为
读 tty_list tty_capture tty_screen tty_expect tty_stats tunnel_list sftp_list sftp_read sftp_tree tty_close 全档放行
写(有界变更) sftp_write sftp_mkdir sftp_rename sftp_remove tunnel_stop 弹授权询问(人允许这一次才执行);无人值守(审批策略 never)确定性拒绝
执行(无界命令面) tty_open tty_run tty_send tunnel_start 同上——这些工具能在本机或远程 shell 里跑任意命令、开监听端口,是围栏敏感面

完全权限档零询问(该档的 intent 就是减少确认步骤);Auto review 档插件不叠加询问 (宿主的模型预审已在逐调用审查)。宿主未组合 sandboxPolicy / approval 服务(老宿主、最小 部署)时不闸(与引入前行为一致),启动日志会打一行 tier-gate: services absent (…), per-call gate disabled 说明这件事。机制(决策矩阵 / 与静态能力 开关 allowProxyCommand 的双层关系)见 docs/permission-tier-plan.md。

典型 agent 流程(推荐):跑完就算完的命令用 tty_run 一次调用拿回输出 + 退出码; 需要长驻的会话用 tty_open(长驻进程用 persistName 要 tmux 持久化)→ tty_send 启动命令 → tty_expect 等就绪标记 → tty_capture{last:true} 拿单条命令结果 → 用完 tty_close。 此外 systemPrompt 里注册了动态 context,每轮对话自动携带活跃终端快照 (sid / kind / cwd / owner,以及 [运行中] 标记),无需先调 tty_list 也有上下文。

agent 开的会话边界(0.20.0):tty_open 开出来的会话是面板里的普通标签(带「agent」 标识),用户看得见、点得开、接管得了、关得掉——刻意不做隐形会话,因为用户不知道机器上 跑着什么正是僵尸会话的来源。它从出生起没有客户端绑定,因此不被孤儿回收器回收 (回收器只收「断连的用户会话」),关闭入口是 agent 的 tty_close 或用户在面板里关标签; 反过来说,agent 也关不掉用户开的标签(tty_close 会明确拒绝)。

进程退出后的「只读保留」(D77)

会话的进程退出不再等于会话消失——它转成只读保留态, 默认保留到显式关闭(EXITED_RETAIN_MS = ∞;宿主 / 插件重启即清空),期间:

  • 读得到:tty_capture(尾部/last,含退出前的最后一批输出)、tty_screen(退出那一刻 的屏)、tty_list(条目带 exited:true + exitCode/signal)都照常可用, 结果里也带 exited 标记;tty_expect 不再空等——它按现存输出立刻结算并带 exited:true;

  • 写不进去:tty_send 明确报错(进程已经没了,写进去只会在死 PTY 上静默消失), tty_resize / tty_stats 同样不再作用于它(stats 如实回 available:false);

  • 不再接回终端:attach 会被明确拒绝(没有活着的 PTY;面板也不会为它建标签), 输出用 tty_capture 读;

  • 谁释放它:agent 的 tty_close(仅限自己开的)、用户在面板里关那个标签、 或宿主 / 插件重启(保留是内存态)。并发上限只数活着的会话(保留态不占名额); 客户端「+」的上限预检也按同一口径数(D85:sessions 帧的 list 含保留态, 直接取 list.length 会把它们算成活会话——那样 agent 跑几条一次性命令之后, 面板里明明没有标签可关,「+」却被自己的客户端拦死); 保留态本身最多留 MAX_EXITED_SESSIONS(16)条,超出按最旧淘汰(淘汰时 logger.warn 记下被挤掉的 sid)——也就是「最近的 16 条退出会话一直可读」, 这是内存与句柄的唯一上界(16 条约 ~20MB 量级)。

    为什么不用时间淘汰:内存与句柄本来就不由时间决定(见上一句),而时间上界对 使用者是第二重惊喜——「命令跑完 → 下一轮读结果」之间隔着人离开、模型排队,多久 都有可能,一个到期就消失的输出比「要主动关掉」更难理解。想要时间上界(或干脆 不保留)把 EXITED_RETAIN_MS 改成毫秒数 / 0 即可:reapExited 那条通路还在, 回收器每轮都会调它;tty_list 的 retainMs 字段也只在按时间释放时才出现。

这就是「tty_open 跑一条会结束的命令、回头再取结果」能直接用的原因——不需要套一层 sh(issue #4 的诉求)。

shell 集成(OSC 133/7,0.4.0)

POSIX 上经既有的 -c 包装层按 shell 类型注入钩子,Windows 上走 PowerShell 的 -File 稳定桩 (都对用户透明,不改 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+)两种形态;
  • PowerShell(Windows):-File 稳定桩(<DSH_HOME|~/.dsh>/tty[/<profile>]/shell/powershell/bootstrap.ps1, 纯 ASCII —— Windows PowerShell 5.1 按当前 ANSI 代码页读无 BOM 的 .ps1,一个非 ASCII 字节就炸解析), 包装 prompt 与 PSConsoleHostReadLine;用户 profile 照常生效(桩在 profile 之后载入,拿到的就是 用户自己那个 prompt,包装后原样调用)。退出码口径:B 时刻快照 $LASTEXITCODE、D 时刻变了才报它 (原生命令),否则按 $? 报 0/1(cmdlet)——刻意不清空 $LASTEXITCODE,免得用户紧接着的 echo $LASTEXITCODE 读到空值;代价是连续两次同码的非零原生命令,第二次降级报 1。 拿不到原 PSConsoleHostReadLine(用户关了 PSReadLine)时一个标记都不发:只发 A/7 会让宿主 报「没在跑」,比「无法判断」更危险(D77);
  • 标记语义:133;A prompt 开始 / 133;B 命令开始 / 133;D;<exit> 命令 结束带退出码 / OSC 7 file://… cwd 上报(tty_list.cwd 跟随 cd, SSH 会话则上报远程路径);
  • 其他 shell(含 Windows 的 cmd)静默关闭;配置 shellIntegration: false 可整体关掉(逃生门)。

回溯匹配与已读水位线(D72):tty_expect 会先回看还没被读过的输出(命令瞬间跑完时 要等的标记早就在缓冲区里了),回看的起点抬到「未读区内最后一个 B 标记之后」——回显落在 A 与 B 之间,所以回显里的字样不会被当成命中(这里刻意不做文本比对:折行 / ANSI / 多行粘贴 都会把它打碎)。

  • 没有 B 标记的环境(未开集成 / Windows 的 cmd 标签 / 远端未装集成 / 用户关了 PSReadLine) 没有边界可切,D75 起 改用另一条判据剔除回显:把最近 tty_send 提交过的命令行从候选文本里削掉再试一次, 只有「削掉后不再命中」才算纯回显——这样「命令没执行、等待却自称 matched」这条假阳性没了, 而回显之后真的跑出来的同一段文本照旧命中。等满超时且 pattern 只命中过回显时,结果带 echoOnly:true,文案会直说「命令大概率没有被执行」。边界:只认 tty_send 写进去的行 (用户在面板里逐键敲的不做行编辑模拟),且命令正在跑(TUI 全屏重画里出现输入文本是真实 输出)时不启用剔除;
  • 回看范围的取舍:起点抬到「未读区内最后一个 B 标记之后」,代价是最后一条命令开始 之前的那段未读输出不在回看范围内。刻意选「宁可漏、不可误报」——把回显当成命中会让 AI 以为事件刚刚发生。要那段内容用 tty_capture。反过来的窄窗口也有一个:tty_send 之后、 新命令的 B 标记到达之前,未读区里仍然是上一条命令的输出,里面有要等的 pattern 就会立即 命中(AI 的下一次调用几乎总在新 B 之后,那种情况下这段已被新 B 切掉);
  • 已读水位线:tty_capture / tty_expect 返回的内容算「已读」,之后的 tty_expect 不再 回扫它们(要回看更早的内容用 tty_capture)。tty_screen 刻意不推进(屏幕是另一种 表示,不是文本流);tty_send 只初始化不推进(刚发出去的命令输出还没被看见);超时也不 消耗未读区(换一个 pattern 还能回溯到同一段)。用户自己开的标签在 agent 第一次触达时 才把水位线落在当下——那之前的历史输出不算「未读」。

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_0_2_10_PASSWORD(点分 IP 的 . 折成 _)。按资源身份派生、不带哈希——与 git-credential-store 的 protocol://username@host、docker credential helpers 的 ServerURL + Username 同一派:host 与 username 本来就是 ASCII 标识符,不需要清洗、 也就不需要哈希兜底。曾经的哈希版是在补救"把人类标签清洗成键":引用文法只认 ASCII, web 01 / web_01(空格与下划线)折出来完全一样,只能靠哈希避免静默覆盖 ✗。资源身份没有这个 死结——撞名只可能发生在同一主机、同一用户、同一端口,而那本来就该是同一个密码(共享是 正确行为)。连接名完全不参与键,所以改连接名/改备注都不会换键。空主机或空用户名则拒绝 存入(键的全部来源,缺一就退化成常量)。代价是可读性弱于人类标签:本对话框的「存入」只按 上面的规则命名,不接受自定名字 ✗;要复用一个自定义名字,就在字段里直接手输 env:名字 (前提是那个名字已在凭据层有值——例如由官方设置卡片 / env 卡片托管)。

  • 派生只发生在「存入」那一刻:存完以后配置里那个 env:NAME 就是唯一事实来源,没有任何地方 会再派生一次——所以改连接名不会让已存的值失效,也不会留下孤儿(旧哈希版才会:连接名一进 键,改名后重新存入就会留下旧名字。要清就按字段里的引用用「清除已存凭据」清掉)。

  • 共享:引用是扁平命名空间,任何按同一套解析的消费方都能用——例如填进 dsh-docker 目标的 password,同一个密码两处复用。

  • 可见性:对话框的引用选择器会列出存储里已有的名字——宿主侧 /api/dsh-tty/credential-refs 只读 refs: 的键名回来(只要名字,值永远不出宿主)。为什么要自己 读:引用半边在协议上不可枚举(原因写在选择器那节),而"我存过哪些名字"正是这个选择器要回答 的问题。这也让孤儿引用(例如改过命名规则后留下的旧名字)重新可见、可选、可清。

  • 解析路径(连接能真正用上存储里的值的关键):连接 / 试连 / SFTP / 端口转发都走同一个 buildConnectConfig,其中的 env:NAME 经官方凭据 provider 解析(resolve,每操作重解析, 改完下一个操作即生效、不必重启宿主);provider 没有这个引用、或宿主没有该服务时才退回 process.env。为什么必须走 provider:凭据存储里的值永远不会被 materialize 进环境 (provider README 原话:"a store the harness owns and never materializes into the environment"), 所以只读 process.env 等于"存进去的密码连接时读不到"。provider 抛错不吞 —— 环境变量也没有时, 错误里会把两个来源一起写出来(否则"凭据服务坏了"会伪装成"你没配")。

  • 保守的边界(务必知道):~/.credentials.yaml 是 0600 的普通文件,没有主密码、没有 OS keychain —— 它挡的是别的 OS 用户,挡不住以你身份运行的进程与 agent;而且它机器本地, 换机器要重存。所以业界共识仍然是:能用密钥 / agent 就别存密码(本插件两者都支持)。

  • 降级:宿主没有 remote.credentials(老版本)时按钮禁用并说明原因,对话框仍可明文保存。

端口转发(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;连接簿改密码后重连自动用新凭证。例外:本地监听失败 (端口被占等)/ 连接簿条目缺失是人工介入级故障——状态停在 error 且 fatal:true、不会自动重试,改配置(或恢复条目)后按新规格重建(见 DEFECTS D58);
  • 多 profile 同跑要错开 localPort:端口转发是机器级资源,而配置按 profile 各存一份(复制 profile 会连隧道一起拷走)。两个 profile 同时跑同一条隧道 → 后起的 那个 EADDRINUSE,状态停在 error 且 fatal:true(不重试,改配置后按新规格 重建);报错文案会直接点明「可能是另一个 DSH profile 的宿主进程」并给出两条出路;
  • 保存时就探测端口(0.26.0):保存隧道配置时宿主会先探一遍本地端口(独占探测: listen 成功即立刻释放),把两类问题提前到保存那一拍说清楚——① 这次提交的配置里 有两条 local 隧道用同一个 localPort(配置层就能判死);② 端口被本进程之外的 东西占着(另一个 profile 的宿主、别的程序)。 探测只警告、不拦保存:返回 200 的响应里多一个 warnings 数组,卡片用黄色警告 档显示它(绿色「已生效」会被它顶掉——那条隧道会停在红色 error 且不重试,说「已生效」 会让人以为一切正常)。为什么不硬拒:卡片保存是整表提交,配置里已经躺着冲突时硬拒 会把用户锁死在「任何一项都存不下去」,而修它恰恰要先能保存。 两条口径值得记:本进程自己正占着的端口被豁免(编辑一条在跑的隧道时端口当然是自己 占的,那不是冲突;reconcile 会先 stop 再 start,端口在同一拍就还回来);停用的那条 也算重名冲突(它随时会被启用)。remote 方向的 remotePort 在服务端监听,不参与本机探测;
  • 状态徽标:卡片展开期间 2s 轮询实时状态(活跃绿/连接中蓝/错误红/停止 灰 + 最近错误);「+」菜单的连接簿条目显示 ⇄N 隧道徽标;agent 可用 tunnel_list 工具查询状态;
  • 连接栏入口(0.20.0):该连接有启用隧道时常驻「隧道 N」按钮(点开看实时状态); 没有隧道时收进「⋯ 更多」——此前没配隧道的连接在界面上完全没有线索, 用户只能自己摸到设置卡片。收进「⋯」既补上发现路径、又不占常驻宽度(连接栏宽度优先 给标签条)。注意隧道必须引用连接簿条目,所以只有条目式连接有这个入口; 「新建连接」对话框里未保存的临时连接配不了隧道。
  • 表单布局(0.20.0):方向用分段控件(本地 -L / 远程 -R,只有两个值,下拉太重); 两端按「主机:端口」成对呈现,中间用箭头示明数据流向(切到 -R 时箭头翻转)。 每个方向都恰好有一端是固定的(宿主硬编码),固定端渲染成静态文本而不是输入框 ——给一个改不动的框是骗人:
    • -L:左端固定 127.0.0.1(本机监听,不暴露到局域网),右端填 SSH 服务器侧目标;
    • -R:左端填 SSH 服务器侧监听地址,右端固定 127.0.0.1(本机被访问的服务)。 样式上两条(用户截图「输入框的样式优化一下」):组的宽度贴合内容、不跟着行宽抻开 (否则左组的端口框后面留一片组内死区,右组的 host 又吃掉全部余量、把冒号与端口顶到卡片右缘); 组内输入框不各自画聚焦环——这一组是 overflow: hidden,2px 外扩的环会被裁成一条竖线 (像多了一道分隔线),改由整组在聚焦时亮起来(test/tunnel-endpoint.test.ts 钉这层成对关系)。
  • 编辑(0.20.0):每行「编辑」把该条回填到下方表单,按钮切成「保存修改 / 取消」。 保存按原始名字定位替换——隧道名由规则派生(<连接簿条目>-L<本地端口>), 「改端口」就等于换名字,用新名字在列表里找不到自己;enabled 保留原值 (编辑规格不会顺手把停用的隧道启用);正在编辑的行有左侧色条标记。 撞名会明确报错(不再静默加 -2 后缀——那会凭空多出一条同名不同尾的隧道)。
  • 本地监听失败是粘性的:端口被占(EADDRINUSE)时状态稳定停在红色 error 并 保留原因,不会被 SSH 侧的重试刷成「连接中」(0.20.0 修)。恢复办法是改端口 (用编辑功能)或先腾出端口。 ⚠️ ~/.dsh/settings.yaml 是各 profile 共享的:同一台机器上跑多个 profile 时, 同一条隧道会被各自启动、抢同一个本地端口——这是配置层面的冲突,不是插件 bug。
  • TOFU 与终端会话共享同一份 hostKeys 钉扎;端口不占用 maxSessions 名额。

SFTP 文件传输(0.7.0,0.8.0/0.9.0 增强)

不动终端、不占会话名额,直接对 SSH 连接做远程文件操作(ssh2 的 sftp subsystem,宿主半体 src/sftp.ts):

  • 落点(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 名额;
  • 断点续传(0.25.0,面板下载):下载中途断线时自动从已收字节接着下—— 宿主侧 /sftp/download 接上了 offset(>0 回 206 + content-range, content-length 报剩余长度;offset ≥ 文件大小 回 416 而不是 0 字节的 200, 免得把「要的东西已经在本地了」误读成「文件是空的」)。判定规则住在纯模块 client-src/download-resume.js(planResume):用户点 ✕ 一律不续(取消的语义 就是「不要了」,自动重试是无视用户意图)、零进度不续、重试预算封顶(默认 3 次), 且续传期间进度条不回零。目录级直传(双栏 ⇨/⇦)仍是整份重传;
  • 双栏拖拽上传与隐藏文件(0.25.0):把一个文件/文件夹从文件管理器拖到右侧远程栏 即上传到该栏当前目录(文件夹经 webkitGetAsEntry 递归展开、目录用 mkdir parents 逐级补齐,与单窗体同源);拖到本机栏会明确提示「没有可执行的动作」而不是静默 ——判定住在 client-src/sftp-view.js(planDrop)。每栏工具行多一个「隐藏文件」 开关(默认关,偏好记在 localStorage):.env / .gitignore 这类条目按需显示; 过滤只在渲染层——宿主 list 与 agent 的 sftp_list 始终照实返回全部条目 (agent 看不到隐藏文件会得出错误结论);
  • 传输通道:POST /api/dsh-tty/sftp/list|mkdir|rename|remove|download| upload(全部 loopback 围栏)。download 另接受可选的 offset(续传)。连接规格经 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 可选,配置 sftpStyle):左本机 / 右远程两栏——本机 侧浏览与文件操作走新增的 /api/dsh-tty/local-fs 路由(list/mkdir/rename/ remove,loopback 围栏);行内 ⇨ / ⇦ 把条目对拷到对面栏的当前目录 (/api/dsh-tty/local-fs/transfer 由宿主服务端把两个路径流式直传,目录 递归、同名覆盖,字节不经过浏览器);单窗体风格照旧,设置卡片切换, 重新打开 SFTP 生效。

会话持久化(tmux,0.10.0)

默认安全模型不变:会话随宿主生死(内核级 PTY 清理)。需要「活得比宿主久」 的工作(dev server、build、训练任务),开持久终端——会话状态委托给 tmux server(专用 socket dsh-tty,与用户自己的 tmux 完全隔离),断线保活 超时、甚至宿主重启后都能接回:

socket 按 profile 区分(0.25.0 起):tmux -L dsh-tty-<profile>,运行时资产 (tmux.conf / inner.sh / shell 桩,<DSH_HOME>/tty/<profile>/)同步分层——多 profile 同跑时各自的持久会话清单互不可见,kill-server 也只影响自己那个 profile。 不带 profile 启动的宿主仍用历史名 dsh-tty(路径也是 <DSH_HOME>/tty/), 所以单 profile 用户升级后行为与路径一字不变。

  • 入口(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 <socket> -f <conf> new-session -A -s dsh-<名>(cwd 由 node-pty spawn 继承);SSH 则 远程 exec tmux -L <socket> -f /dev/null new-session -A -s dsh-<名> 开 pty channel(<socket> = tmuxSocketName(),0.25.0 起按 profile 区分)。-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 连接对话框勾选「保存到连接簿」即存为条目(同名覆盖, 名称留空用主机名);「+」菜单条目的 ✎ 与设置卡片里的 编辑 走的是同一套表单—— 连接 / 认证 / 选项 三段分组 + 凭据存储 + 凭据引用选择器 + 试连 + 文件浏览, 支持改名、同名冲突校验。「连接簿名称」这一行常显(0.31.0):在名称框里填了名字就自动 勾上「保存到连接簿」、清空就自动取消、手动取消会把名字一起清掉——按下去会保存什么, 界面上始终对得上。对话框本身是三段式外壳(0.31.0):标题与底部动作行固定, 只有中段滚动;低频项(跳板机 / 代理命令 / 主机密钥别名)收在「高级」折叠区里—— 条目里配过就默认展开、没配过就收起(收起时那一行会点名里面配了什么,不是暗盒)。 两者只差提交方式:菜单对话框是「保存修改 / 连接(并保存)」 立即落盘,设置卡片是「应用」进卡片表单、再随卡片「保存」写入配置;
  • 认证方式(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;
  • 主机密钥别名(hostKeyAlias,0.25.0):TOFU 的记录/比对默认按连接地址定位, 而同一台真实主机常常经不同地址触达——跳板机后面写内网 IP、或经端口转发落在 127.0.0.1:2222。那时「同一把钥匙、不同记录」会让每次切换地址都判成指纹变更 (假 MITM 告警并拒绝连接),而用户的 known_hosts 里本来就只有一条。填同一个别名 即可让这些地址共用一条主机密钥记录。语义与 OpenSSH 的 HostKeyAlias 对齐:只决定 指纹记在哪条记录里,不改连接地址;留空 = 按地址定位(与从前逐字一致)。 ~/.ssh/config 里的 HostKeyAlias 会照原样导入(它是纯本地的定位开关,不含可执行 成分,信任级与 ProxyCommand 完全不同)。四个连接点(终端 / SFTP / 隧道 / 试连探针) 共用同一份键推导,所以「试连说匹配、真连说变更」这种自相矛盾不会出现;
  • 标签与状态: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 不展开),同名跳过,随「保存」写入;HostKeyAlias 也在 映射之列(0.25.0)。 每一种跳过都会当场说明(本轮起):依赖跳板机的块(ProxyJump / ProxyCommand) 不导入并点名——本版本不支持跳板机,导进来只会得到一条连不上、且只报通用超时的条目; 超过导入上限的条数与「非具体主机」的条数也各自报数。ProxyJump none / ProxyCommand none 是显式直连,照常导入。 ProxyJump 会解析成连接条目的跳板机(单跳)一起导入:值可以是 user@host:port (IPv6 写 [::1]:22),也可以是同一份 config 里的另一个具体 Host(按块名解析,块序任意); 跳板机缺省继承目标那一跳的凭据,要不同的钥匙就在条目里填 jump 的 keyPath/password。 解析不出来的(别名缺失、别名自己又依赖跳板机)仍会跳过并点名。 ProxyCommand 永不自动导入(单独报数并说明为什么):它等于让设置字段驱动本地任意 命令执行,所以只在手动填写 + 打开开关之后才会跑(见下一段)。 对话框里也有「跳板机」一段:一个 [用户@]主机[:端口] 输入框(与 OpenSSH 写法一致), 勾「使用独立凭据」才展开用户名 / 认证方式 / 私钥 / 口令;条目行显示「⇢ 经 X」,「试连」 会把跳板机那一跳单列出来;
  • 代理命令 / ProxyCommand(0.21.0,默认关):连接簿条目(或连接对话框)里可填一条 本机命令,它的 stdin/stdout 就是到目标的 SSH 传输——等价于 OpenSSH 的 ProxyCommand (典型写法 ssh -W %h:%p bastion)。支持 %h 目标主机、%p 端口、%r 用户名、%n 主机、 %% 字面 %,其余 %X 原样保留;代入值只允许字母数字与 ._@:[]-,含 shell 特殊字符 直接拒绝执行(不做「按平台各写一套转义」:命令最终交给 sh -c / cmd /c,两边规则不同)。 闸门:设置卡片「允许 ProxyCommand(本机执行命令)」默认关,它的提权有两条通道: ① 启动环境变量 DSH_TTY_ALLOW_PROXY_COMMAND=1(最严档,进程启动时采样,只认继承来的 process 层——写项目 .env 或 ~/.dsh/env.yml 不算授权);② 就地提权(免重启): 卡片上点那个开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内解锁并替你打开开关。 HTTP / 界面不能凭空打开它(给 true 而无授权会被 400 拒绝并说清两条路),但关掉永远 可用(紧急刹车不能依赖重启)。授权是持久的(落在 <DSH home>/dsh-kit/capability-grants.json, 0600):宿主重启后直接生效、不再确认,所以每次启动会打一行 [dsh-tty] elevation: load capability=… via=file grantedAt=…,卡片上也显示「已授权 · 时刻」并给 「撤销宿主授权」入口。升级影响:升级前靠界面打开的这个开关会变成关。为什么这么设计: 回环围栏与同源证明都拦不住跨站页面与页内脚本,而这一档是「配置里写一行就在本机跑命令」—— 配置界面若能凭空提权,这道闸门等于没有(细节见 architecture.md § 7)。关着时携带代理命令的连接 明确失败、不退回直连(直连多半也连不上,还会把配置问题伪装成网络问题),「试连」在 阶段 0 就返回并点名这个开关。与跳板机同时填时按 OpenSSH 语义跳板机优先,并记一条 warn。 闸门只有一处(本插件 settings):docker 容器面板读同一个开关——连接簿配一次、两处生效; 代价是代理命令是本机执行的,docker 目标也会用这条命令(详见 docs/proxyjump-plan.md §6.1 的闸门定案表); 与跳板机一样,它是另一条独立传输,收尾必须成对(终端 / SFTP / 隧道 / 探针四条路都在 同一个收尾入口里 dispose():关传输 + 杀进程组,子进程不会变成常驻孤儿);
  • 凭据引用选择器(0.4.0,0.17 起与 env 插件解耦):SSH 对话框的密码/口令字段旁有筛选框 + 限高列表,候选 = 凭据存储里已有的引用名(宿主读 .credentials.yaml 的 refs: 键, 只回名字、绝不含值)∪ 本机连接簿里已经在用的引用名;点击即填 env:NAME,也可手输 任意 env:NAME(连接时按凭据层解析:provider 优先、process.env 兜底)。 为什么读文件 / 为什么不用 env 插件的托管清单:引用的发现路径官方定的是"配置界面从自己的 settings schema 得知有哪些引用"——引用半边故意不可枚举(@deepseek-ai/dsh-credentials 的 listRecords 注释原话:"the reference half, which has no enumeration because configuration surfaces learn which references exist from settings schemas"),浏览器侧的 ctx.remote.credentials 也只开 describe / set / unset,连 listRecords 都没开。于是 "这本存储里到底存过哪些名字"在浏览器侧根本问不到 ✗ —— 而选择器的用途恰恰就是"我存过什么、 能不能复用"。所以宿主侧开了一条只读通路 /api/dsh-tty/credential-refs(loopback 围栏, 只解析 refs: 的键名、不返回值,见 readCredentialRefNames)。这是有意偏离官方"引用不可 枚举"设计的一处(代价:引用名会进浏览器,值不会);若要完全守官方口径,就只用连接簿那一半。 边界:本地 provider 若被配了自定义 path / dshHome,那些引用这里看不到;宿主读取失败或旧 宿主没有这条路由时,候选安静退回连接簿那一半。 候选一个都没有时,这一行整体退化成一行说明(不再摆一个永远点不开的下拉——那看着就像坏 了):文案点明要么勾上面「保存时存入凭据存储」新建一个,要么在字段里直接手输 env:NAME; 口令那行上方没有勾选框,文案相应改成只提手输。设置卡片的编辑表单里有同一行、同一份候选 (同样只在筛选框获得焦点时展开),差别只在呈现:卡片里是内联列表而非浮层——卡片本身是 可滚动的长表单,浮层在那边会被裁掉;
  • 主机指纹 TOFU 钉扎(0.3.0):首次连接成功后把该主机(host:port)的 sha256 指纹记录进 hostKeys(随 settings 持久化);之后每次连接校验, 集合内任一指纹匹配即放行,指纹变更直接拒绝连接(防中间人冒充),错误 信息带重置指引。同一主机的多把钥匙(0.19.0,如 rsa + ed25519)各记一条 指纹、合并存于同一记录——算法协商变化不再误报变更。主机重装/换钥匙后, 到 设置 → 插件 → 终端面板 → 「SSH 主机密钥记录」删除对应记录再重连即可 (记录列表支持删除)。「从 known_hosts 导入」(0.4.1):一键解析 ~/.ssh/known_hosts 把已有主机指纹批量预填充,同主机的 rsa/ed25519 等多条 记录全部保留(不再只留首条);连接簿里的主机名还会用于还原 |1| hashed 条目,非默认端口按 [host]:port 解析;
  • 连接测试(0.11.0):设置卡片连接簿条目行内「测试」按钮,SSH 连接 对话框另有「试连」按钮——两者都只做链路诊断(不建会话、不占 maxSessions 名额、不开 shell):先 TCP 预检(DNS + 建连,失败分类为 拒绝/超时/DNS/不可达),再 ssh2 握手做主机密钥 TOFU 比对,最后完成 认证。结果逐段展示:成功显示耗时与「主机密钥匹配 / 已记录」;失败给出 具体阶段原因(如「认证被拒绝:所有认证方式均失败」「主机密钥不匹配」 附已记录与本次指纹 + 重置指引)。连接簿条目「测试」 完整 TOFU:首测 即把新指纹记录进 hostKeys(与真实连接同语义);对话框「试连」 只 比对不落盘(草稿未入库不建立钉扎)。
  • 计入 maxSessions 并发上限;关断与本地会话一致:标签 ✕ / kill 帧关闭 ssh2 channel,exit 帧照常带回退出码 / 信号。

服务器状态条(0.17.0)

(图中是本机会话;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 过渡;窄窗口下横向滚到右侧看 网络 时, 滚动位置不会被下一秒的刷新弹回开头。

AI 辅助「失败即解释」(0.24.0,默认关)

命令以非零状态结束时,终端右下角浮出一枚低调的徽标(写着退出码);点它才把那条命令的输出 尾部发给模型,换回一段「发生了什么 / 下一步」。

  • 零输入,但不自动外发:徽标只提示「这条失败了,要看看吗」——把终端内容送出去的那一步永远是 用户点的。Ctrl-C(130)与 SIGPIPE(141)不弹(它们天天出现,为它们弹徽标等于逼用户把功能 关掉),退出码拿不到时也不弹。
  • 发什么:那条命令的输出尾部(剥掉颜色与 \r 覆盖行、折掉连续重复行、截断到 40 行 / 6000 字符, 密钥 / token / 连接串里的密码已遮盖)加上当前屏幕——不发 scrollback。两段互补:输出回答 「这条命令干了什么」,屏幕回答「敲的是哪条命令」(实测输出里没有命令回显)。
  • 答案怎么用:浮层表头是「这条命令为什么失败」加一枚退出码药丸;模型给的命令单独渲染成 「建议命令」一块,按钮是「填入命令行」——只把字节写进提示符(先发一个 Ctrl-U 清掉可能已躺着的 半截输入)、绝不回车;另给「复制答案」与「关闭」。模型只给了文字解释(没有命令)时就不显示 命令块。答案只活在面板 UI 里,不会回显进终端。
  • 失败时也有出路:宿主报错(没有模型路由 / provider 报错 / 超时)时,浮层给的是一块可复制的 「复制报错」,而不是一行没有任何按钮的死文案。
  • 同一条失败只问一次:答案按那条命令缓存——Esc 收掉浮层再点徽标会直接回放,不会重新问一遍; 只有上一次报错时再点才会重试。宿主侧另有一道按会话的在途去重,两个标签页同时点也不会花两次调用。
  • 模型路由:候选来自宿主已注册的模型目录(provider / model 成对;留空则跟随宿主默认模型)。 没配到时浮层里直接说明「没有可用的模型路由」,而不是点了没反应。
  • 一个控件管一对(渠道 + 模型):provider 与 model 是一对,单独任何一个都不成立(宿主会明确报 「只填一个不生效」),所以卡片里是一个「模型路由」控件,显示成 provider/model。点开就列出宿主已注册 的渠道(按渠道分组,一次往返取回整张表),点一条同时把两个键写好。控件是只读的——没有手输: 候选就是宿主能路由的集合,而「目录是空的」那种状态意味着 DSH 自己的对话也选不出模型,不是这里要 兼容的输入。候选是浮层(不占卡片布局),第一行固定是「跟随宿主默认模型」(留空即跟随), 当前生效的那条会高亮。
  • 候选取不到时照实说:界面把「拿不到候选:宿主可能还没重启」与「宿主没有可用的模型」分开说, 不混成一句——这条路由是宿主半体加的,只刷新页面不会生效。
  • 隐私:默认关。打开它等于授权一次终端内容外发——只发上面那一段,且发送前做轻量遮盖 (只按形态明确的规则,宁愿漏一个罕见格式也不错杀正常日志)。

配置(设置 → 插件 → 终端面板,保存即热生效)

设置面在宿主里的位置随 DSH 版本变,但始终是同一份表单:0.2.0-rc.1 起挂 plugins.bundle.config——插件详情页「说明」正下方内联,不必再点「>」进二级页;旧宿主 (0.1.6 线)自动回退到侧边栏「插件」页里该行的「>」子页,≤0.1.5 用设置页卡片。

项 默认 说明
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, hostKeyAlias='', persist=false};保存时整体替换、同名覆盖;password / passphrase 支持 env:VAR 引用,避免明文入库;持久化开启时条目点击默认以 tmux 持久会话打开,persist=false 是取消项;hostKeyAlias 空串 = 按主机地址定位指纹(TOFU),填了则按别名定位(同一台主机经不同地址触达时共用一条记录,见「SSH 连接」一节)
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 生效
persistence off 会话持久化:off 会话随宿主生死(默认);tmux 开启后所有新开的标签默认由 tmux server 托管、可跨宿主重启恢复(需本机/远程安装 tmux);SSH 对话框可对单次连接取消
endOnPageClose false 页面(最后一个连接)断开且保活期结束时,是否连 tmux 持久会话一起结束。默认 false = 留存可恢复;true = 页面关了就不保活(保活期内刷新仍可无缝接回)
sftpLimits {maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000} SFTP 传输限制(浏览器侧保护,均为 0 = 不限):maxDownloadMb 单文件下载上限(超限中止并提示用双栏 ⇦/终端 scp)、maxUploadMb 单文件上传上限、maxUploadFiles 一次批量/拖拽上传的文件数上限;大文件请走双栏 ⇨/⇦ 服务端直传(字节不经过浏览器,不占内存)
allowProxyCommand false 允许连接簿条目里的代理命令(ProxyCommand)在本机执行。默认关:关着时携带代理命令的连接明确失败(不退回直连),~/.ssh/config 导入永不自动带入该字段。docker 容器面板读同一个开关(连接簿只有一处)。支持 %h/%p/%r/%n/%% 展开,代入值走白名单;命令的子进程不会残留(四条连接路径共用同一收尾)。两条提权通道:① 启动环境变量 DSH_TTY_ALLOW_PROXY_COMMAND=1(判定源是宿主的启动环境快照,只认继承来的 process 层:写项目 .env 或 ~/.dsh/env.yml 不算授权);② 就地提权(免重启):设置卡片点那个开关 → 面板给出「在宿主终端执行」的命令 → 十秒内解锁。HTTP / 界面不能凭空打开它(给 true 而无授权 400 并说清两条路),但关掉永远可用;配置里的 true 不算授权。授权持久(<DSH home>/dsh-kit/capability-grants.json),重启后直接生效并在卡片上显示时刻与「撤销宿主授权」(见 architecture.md § 7)
assistEnabled false AI 辅助「失败即解释」(0.24.0)。默认关:开启后命令非零退出时右下角出现徽标,点它才把该条命令的输出尾部(清洗、去重、截断、密钥遮盖后)连同当前屏幕发给模型;不发 scrollback。模型给的命令只会「填入」命令行、永不回车。关着时宿主侧也拒绝 /api/dsh-tty/assist
assistProvider '' AI 辅助的模型路由 provider;与 assistModel 成对(都留空 = 跟随宿主默认模型)。只填一个既不生效、也不会静默回落到默认模型
assistModel '' AI 辅助的模型路由 model;与 assistProvider 成对
statsEnabled true 服务器状态条(0.17.0):按标签可见性采集并推送 CPU / 内存 / 磁盘 / 在线时长 / TCP 连接数 / 网速 / CPU 温度;关闭即停表(远端 exec channel 一并关闭),保存即热生效

连接栏扩展点(客户端服务 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 <同一个 shell> -c <command>'——外层 exec 让进程就是命令本身 (退出码与信号忠实),内层 -c 才保证整段命令被当 shell 代码执行:cd x && cmd、 a; b、for …; do …; done 都完整跑(D78:旧写法把命令直接缀在 exec 后面,只跑第一条, 内建命令还会直接结束外层 shell)。
  • 命令标签会自动重开:宿主重启 / 断线重连后 sid 已失效,客户端对 spawnSpec.command 的标签按原规格重新执行命令(普通非持久标签维持「点击重试」 的旧行为)。页面刷新后同样按原命令恢复。
  • 命令来自宿主侧插件(不是远程用户输入),信任级与插件本身相同;tty 只校验 形状:非空、单行、长度 ≤2000(换行会破坏本地 -c 包装层)。
  • 「单行 ≤2000」是这条通道的约束,不是 tty_open 的(2026-09-27 复核指出): spawn / ssh 帧的 command 要拼进 -c 包装层,所以由 sanitizeCommand 拒绝 换行与 NUL;而 agent 的 tty_open.command 走另一条路——命令整段用 shSingleQuote 包住交给内层 -c(见上一条),可以含换行(多行脚本实测可跑), 只受工具参数的字符串约束。两条通道的入参规则不同,别把这里当 tty_open 的限制。
  • 服务名 ttyTerminal 同样未声明在 Context 类型面上,按可选依赖注入;tty 未安装 或版本 < 0.14.0 时不会触发(dsh-docker 会退化为「复制命令」)。
  • ttyTerminal / WS spawn 是「没有静态能力闸」的命令面(2026-10-07 补,消费方文案别写反): 本服务与 {t:'spawn'} 帧都直接接受任意 command,不受任何 DSH_*_ALLOW_* 管辖(本插件的 那个静态闸只覆盖 ProxyCommand 这一档「配置字段驱动本机命令」)。这是刻意的——终端面板本身就是 产品,它的权限上界由用户的直接操作承担:人点「+」开标签、人在里面敲命令。 务必看清 tier gate 管不到这里:上面的 tier gate 挂在 tools/pre-execute 瀑布上、只按 tty_ / sftp_ / tunnel_ 前缀过滤(TTY_TIER_PREFIXES),也就是只闸 agent 工具调用; 浏览器发来的 spawn 帧与 ttyTerminal.open 是 WS / 客户端服务通道,完全不经过它。 所以别把它当这条命令面的闸门——它一个字节都不拦(tty_open 那条 agent 路径才受它管)。 代价:任何消费方都不该把「配上 tty 之后本插件自己的 exec 开关」宣传成「进不了容器」的 边界——装上 tty 后,页内脚本经 ttyTerminal.open 或 WS spawn 就能拿到比一次性 exec 更强的 任意命令面。威胁模型边界见 architecture.md § 7;docker 侧的同一句边界写在 它的 README「已知限制」。

就地嵌入终端(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(消费方在这

…

内容来自项目 README(GitHub)↗

链接

同类插件

查看整个分类 →

社区评论

评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。