# @hyzyn/dsh-tty 中文文档

规范地址：https://npmcn.com/package/%40hyzyn/dsh-tty/

版本：0\.36\.1
npm 元数据更新时间：2026-11-01T08:38:00.646Z
中文译文更新时间：2026-11-01T10:07:22.971Z
软件包许可：Apache-2\.0

本文由上游 README 机器翻译，可能存在偏差，请结合原文核对 API、命令与配置。版权与许可归原作者所有。

原始 npm 页面：https://www.npmjs.com/package/%40hyzyn/dsh-tty
原文对照：https://npmcn.com/package/%40hyzyn/dsh-tty/?lang=original
项目主页：https://github.com/hyzyn/dsh-plugin-kit/tree/main/packages/tty#readme
源码仓库：https://github.com/hyzyn/dsh-plugin-kit

## 关键信息（来自 npm 元数据）

- 包体积（解压后）：3.1 MB，73 个文件
- 模块格式：ES Module
- TypeScript 类型：包内自带
- Node.js 要求：\>=22\.19\.0
- 运行依赖：5 个
- peer 依赖：@deepseek-ai/dsh ^0\.1\.7-rc\.2 \|\| ^0\.2\.0-rc\.1 \|\| ^0\.2\.0-rc\.2 \|\| ^0\.2\.1-alpha\.1 \|\| ^0\.2\.1-alpha\.2 \|\| ^0\.2\.1-alpha\.5 \|\| ^0\.2\.1-rc\.1 \|\| ^0\.2\.1-rc\.2 \|\| ^0\.2\.1-rc\.3 \|\| ^0\.2\.1-rc\.4 \|\| ^0\.2\.1-rc\.5，@deepseek-ai/dsh-tools ^0\.1\.7-rc\.2 \|\| ^0\.2\.0-rc\.1 \|\| ^0\.2\.0-rc\.2 \|\| ^0\.2\.1-alpha\.1 \|\| ^0\.2\.1-alpha\.2 \|\| ^0\.2\.1-alpha\.5 \|\| ^0\.2\.1-rc\.1 \|\| ^0\.2\.1-rc\.2 \|\| ^0\.2\.1-rc\.3 \|\| ^0\.2\.1-rc\.4 \|\| ^0\.2\.1-rc\.5
- 首次发布：2026-08-24T14:35:39.435Z
- 最新版本发布：2026-11-01T08:38:00.493Z
- 已发布版本数：55（近 12 个月 55 个）

## 中文简介

DSH Web GUI 的终端面板插件：侧边栏「终端」提供 xterm.js + PTY 全交互终端与 ssh2 原生 SSH 远程连接（连接簿、主机指纹 TOFU、跳板机），支持 tmux 会话持久化、SFTP 文件传输、端口转发与服务器状态条，并向其他插件开放 ttyTerminal / ttyPanel / ttyConnbar 扩展点；Windows 宿主同样可用。

## 安装

```sh
npm install @hyzyn/dsh-tty
```

也可以使用：`pnpm add @hyzyn/dsh-tty`、`yarn add @hyzyn/dsh-tty`、`bun add @hyzyn/dsh-tty`

## README

# @hyzyn/dsh-tty

中文 | [English](https://npmcn.com/package/%40hyzyn/dsh-tty/README.en.md)

> 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 辅助](https://npmcn.com/package/%40hyzyn/dsh-tty/#readme-ai-%E8%BE%85%E5%8A%A9%E5%A4%B1%E8%B4%A5%E5%8D%B3%E8%A7%A3%E9%87%8A0240%E9%BB%98%E8%AE%A4%E5%85%B3)。

![终端面板：xterm 弹窗 + 服务器状态条 + 资源明细。终端上方是完整模式的细监控条（CPU 百分比与迷你条 / 核心 / 内存（已用占总量）/ 磁盘（已用占总量）/ 在线 / TCP / CPU 温度 / 网络上下行），右下方是展开的资源明细浮层（每核柱 / 上下行双线 / Top 进程）](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-tty.png)

## 安装

```bash
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）。

![终端面板设置卡片：shell / TERM / 并发上限等保存即热生效](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-tty-setting.png)

## Windows 宿主（best-effort）

Windows 支持是 **best-effort**：默认 shell 取 `%COMSPEC%`（系统保证存在）；要 PowerShell 就在设置卡片把 「Shell 路径」填成 `powershell.exe` / `pwsh.exe` 的完整路径（候选列表给 `%COMSPEC%` + Windows PowerShell 5.1 + PowerShell 7（装了才有），默认项排最前）。**Windows 上不做 POSIX 包装层**：直接 `[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）**：conhost 的 Enter 是 **CR**，裸 LF 只把光标下移一格、**不提交命令行**。 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 家族里**解析错误**（5.1 上的 `&&`、两版上的 `echo (1`）命令一条都没跑，而 D 标记报 **0** —— 不能只看退出码判成功，报错文本在输出里。
    -   **桩的两条边界**：靠 `-ExecutionPolicy Bypass`（进程级）才载得进来——**组策略**把执行策略锁成 Restricted / AllSigned 时该参数压不过 GPO，桩载不进去、标签照开而集成静默失效（与「用户关了 PSReadLine」 同一种降级）；这个参数还会把值写进 `$Env:PSExecutionPolicyPreference`，从该会话里再起的 PowerShell 会继承它。
    -   **已知副作用**：包装 `prompt` 会让用户原 prompt 读到的 `$?` 变成我们那几次写入的成败（恒真），依赖 `$?` 的失败提示会失效。
    -   **未复现的记录**：曾有记录说 PowerShell 7 上命令输出在 ConPTY 里晚于 D 标记抵达、`tty_capture{last}` 可能 取到空；2026-10-13 在 pwsh **7.6.6**（Win11 ARM64）上按三档输出量复测 **0/20 空窗**，未复现——真遇到就用 `tty_capture` 读尾部，并把当时的 pwsh 版本报回来。
-   **不支持的两项（宿主侧自动关掉，设置卡片里写明原因）**：**tmux 持久化**（Windows 上没有 tmux，本地标签照常 打开但不受托管；SSH 到 Linux 主机仍可用）；**服务器状态条**（本地只拿得到 CPU / 内存 / 在线时长（`node:os`）， 磁盘 / TCP / 网速 / 温度显示「无」；远端 Windows 主机走 PowerShell 那一跳，见「服务器状态条」一节）。

0.18.1 之前为什么一条本地终端都开不起来、修复落点、真机验证范围与 `windows-smoke` 的 W6–W8 覆盖，见 [ROADMAP § 已完成](https://npmcn.com/package/%40hyzyn/dsh-tty/ROADMAP.md#%E5%B7%B2%E5%AE%8C%E6%88%90%E8%90%BD%E7%82%B9--%E9%97%A8%E6%A7%9B) 的「Windows 宿主（0.18.1）」一节。

## agent 工具

插件向 agent 注入十九个工具（操作实时显示在用户终端里；**每次调用受会话权限档位约束**，见下节）：

> **零配置时只注册 9 个**（0.33.0 起按配置条件注册）：`sftp_*` 七个要有 SSH 连接簿条目、`tunnel_*` 三个要有隧道——没有对应配置就没有可调用的对象，注册了只会让模型对着必然报错的工具打转。上表是**配齐之后**的完整面；受限档位下还会进一步裁掉写 / exec 类（见下节）。

| 工具 | 作用 |
| --- | --- |
| `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` 且**不杀会话**（继续等或关掉由调用方决定）；**调用方取消**（`exec.signal`）同样立刻返回，结果带 `cancelled:true`——取消的是这次调用，**不是那条命令**，会话照旧留着（2026-10-30）。何时用它而不是 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 界面。正文保尾最多 32KB：超出时返回 `truncated:true`，渲染文本末尾提示用 `tty_capture` 取完整滚动输出（大尺寸屏幕装满文本才会撞到） |
| `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）；**调用方取消**（`exec.signal`）立刻结算为 `cancelled:true`（不是超时，也不消费未读区，2026-10-30） |
| `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` 1~8 / `maxEntries` 1~2000 限流，超限 `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](https://npmcn.com/package/docs/permission-tier-plan.md)。

**受限档的会话连工具面也一并裁掉**（试点，宿主的 `tools.restrict`）：档位是仅可查看 / 工作区内修改时， 插件在该会话的 **agent 作用域**上把上表的「写」「执行」两类从工具表里摘掉——agent 看到的 tty 工具从 19 个降到 10 个（只剩「读」那一行）。**安全边界仍然是上面那张逐调用的矩阵**：裁剪只是提前把按当前 档位不可能成功的工具拿走，省掉「试 → 被拒 → 换个工具再试」的往返；名单漏了谁、或工具在会话建立 之后才注册，都只影响模型看不看得见，不构成越权（调用照样被闸拦住）。完全权限 / Auto review 档与宿主 未组合档位服务时**不裁**（与闸的 allow 分支同一口径）。日志里有一行 `tier-restrict: agent <id> mode=… → tty 工具 19 → 10（裁掉 N 个 write/exec：…）`。 **会话中途切档会重算**（2026-10-25 起）：宿主把档位写成会话事件（`sandbox/mode` / `approval/policy` / `permission/preset`），kit 按会话 id 找回 agent 重算一次——升档把工具放回来、 降档重新摘掉，都在下一次模型请求之前生效（工具表在请求构建时投影）。**现状边界**：别的会话不受影响； 三条旋钮事件一次切档最多一起来，合并成一次重算。

典型 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`；例 `operator@192.0.2.10:22` → `DSH_TTY_OPERATOR_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」按钮（点开看实时状态）； **没有隧道**时收进「**⋯ 更多**」——此前没配隧道的连接在界面上**完全没有线索**， 用户只能自己摸到设置卡片。收进「⋯」既补上发现路径、又不占常驻宽度（连接栏宽度优先 给标签条）。注意隧道必须引用连接簿条目，所以只有条目式连接有这个入口； 「新建连接」对话框里未保存的临时连接配不了隧道。**2026-10-31 起「⋯」恒在**——它不再只是 隧道的兜底入口，还装着「复制连接信息 / 复制 SSH 命令」，见下节「连接栏：连接身份与复制」。
-   **表单布局（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`）：

![SFTP 单窗体（0.16.0 起挂在终端下方抽屉）：远程目录浏览，行内 下载/重命名/删除](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-tty-sftp-dialog.png)

![SFTP 双栏（0.9.0；0.16.0 起挂在终端下方抽屉）：左本机 / 右远程，行内 ⇨/⇦ 服务端直传](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-tty-sftp-dual.png)

-   **落点（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](https://npmcn.com/package/docs/architecture.md#7-%E4%B8%80%E6%9D%A1%E8%AF%B7%E6%B1%82%E7%BB%8F%E8%BF%87%E4%BB%80%E4%B9%88)）。关着时携带代理命令的连接 **明确失败、不退回直连**（直连多半也连不上，还会把配置问题伪装成网络问题），「试连」在 阶段 0 就返回并点名这个开关。与跳板机同时填时按 OpenSSH 语义**跳板机优先**，并记一条 warn。 闸门只有一处（本插件 settings）：docker 容器面板读同一个开关——连接簿配一次、两处生效； 代价是代理命令是**本机**执行的，docker 目标也会用这条命令（详见 [docs/proxyjump-plan.md](https://npmcn.com/package/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）

![极简模式的服务器状态条 + 同一张资源明细浮层：CPU / 内存 / 网络 三项，每项一条近 60 秒趋势线加大数字](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-tty-stats.png)

（完整模式的条见页首那张图；两种形态由面板头部右上角那颗**三档按钮**切换。两张图都是 **SSH 会话**，本机会话的条用的是同一套字段。）

每个**可见**标签在终端上方挂一条细状态条，实时展示该会话所属主机的资源指标 （观感对齐 FinalShell 的会话监控条）：

```
CPU ▮▮▯▯ 6% │ 核心 16 │ 内存 ▮▮▮▮ 78% (49.6/62.3 GB) │ 磁盘 ▮▮▮ 63% (40.8/68.8 GB) │
在线 2w4d7h16m │ TCP 570 │ CPU温度 无 │ 网络 ↓92.3K/s ↑93.1K/s
```

-   **一项资源一条**（2026-10-31，对齐 FinalShell 的会话监控条）：百分比、迷你进度条、已用/总量 同组（`内存 78% (49.6/62.3 GB)`），`内存` / `磁盘` 的标签只出现一次；单位相同时只写一次。 此前是 `内存 78%` 与 `内存 49.6 GB/62.3 GB` 两条并列，标签重复、视线要来回跳。
    
-   **极简模式（2026-10-31）**：面板头部右上角（搜索 / 清屏旁边）有一枚**三档循环按钮**： **完整 → 极简 → 隐藏 → 完整**（偏好存在浏览器 `localStorage`，不进 profile——它是显示形态， 不该为它改配置；「隐藏」会同时停掉当前标签的采集，宿主采集器与远端 exec channel 一并停）。 极简条只留 **CPU / 内存 / 网络** 三项，每项是「小标签 + 最近 60 秒趋势线 + 大数字」：趋势线比瞬时进度条多给 一条信息「在涨还是在抖」，线下是一层**向下淡出的面积填充**（渐变锚在图表高度而不是曲线包围盒上—— 锚包围盒时，一条几乎水平的线（内存常年 80%±1）会被压成一整条实色带，看着像进度条）。 **折线本身是横向的「时间流向」渐变**（左端 38% 不透明度 → 右端满：过去暗、当下亮，视线被带到 最右一帧），外面罩一圈**极弱的同色光晕**——深色条上 2px 细线不加光晕会「飘」，这是 2026-11-01 那轮视觉改造的直接原因。CPU 与内存两项（0~100）还各有一条 **80% 警戒虚线**：缩略线没有刻度， 光看波形分不出「三成」还是「八成」；网络那项不画（字节速率的纵轴随量程走，「80%」在上面没有含义）。 线色与填充随档位换色：**<80 绿 / 80~90 橙 / ≥90 红**（`STATS_WARN_LIMIT` 一处取值，与虚线画在 同一个位置——阈值和虚线位置一旦分家，72% 的线会已经变橙却还在虚线下面）；颜色取终端固定深色 调色板，不借主题语义色（浅色主题的深绿压在深色终端条上对比度只剩一半）。网络那格 **↓ 下行浅蓝 / ↑ 上行青绿**，两段独立着色。极简条 略高（36px）给趋势线呼吸空间，终端行数随之重算。网络用**对数刻度 + 64 KB/s 地板**（量纲跨几个 数量级，线性刻度会把空闲画成一条贴底直线）；掉帧画**断线**，不补值。磁盘 / 在线 / TCP / 温度留在 完整模式。
    
-   **资源明细（悬浮仪表盘，2026-10-31）**：状态条最右端的 **▾** 展开一张悬浮卡片，三格并排—— **CPU**（每核一根柱，按 80/90 阈值变橙/红；柱子上方一条 **80% 的虚线警戒线**——柱状图没有 Y 轴刻度，光比高低分不出「一半高」是 50% 还是 80%，而这条线的位置就是柱子变橙的那一档； 标题**紧跟着**报「最差那一核」的档位，平均值会把「16 核里有一核烧到 95%」摊平）、 **网络 I/O**（近一分钟上下行双线：**下行更粗、发光、带渐变面积；上行细一档、压暗一档、 不发光**——两条线的色相本来就挨着（蓝 / 青），光靠颜色在交叉处认不出谁是谁；试过虚线分层， 小尺寸锯齿线配虚线观感不好，改用粗细 / 亮度 / 光晕三层。图例贴在标题与「近 1 分钟」之间， 色条与曲线同色同粗细。两条线各带一条横向「时间流向」渐变（左暗右亮） 与一圈很弱的光晕，面积首档从 .40 收到 .30——96px 高的一整块底色在视觉上比相邻的柱子重得多； 横轴**固定** 60 秒，刚打开时左边的空白就是还没攒到的历史，不把 5 个点拉伸成「这一分钟都这样」。 **Y 轴是 1-2-5 量程 + 15% 顶部余量 + 平方根刻度**：线性刻度下一次突发会把整分钟的常态压成 贴底直线，纯对数又把同一数量级内的起伏抹平（58 KB/s 与 163 KB/s 只差 8% 高度），0.5 次幂 两者兼顾；峰值因此永远落在量程内、不会顶到边框。两条参考横线是**低透明度虚线**且画在曲线 下面，不抢戏）、 **进程**（默认按 CPU 降序的 Top 5，宿主每 5 秒采一次 `ps`；上送的是 **CPU 前 5 ∪ 内存前 5** （按 pid 去重，最多 10 条）——只发 CPU 前 5 的话，切到「按内存降序」时只能在那 5 条里重排， 一个 1.2 GB、CPU 却只有个位数的进程（微信）根本不在候选集里，内存榜第一名就成了某个 Chrome 辅助进程。**内存列取 `ps` 的 `%mem`（= RSS）**，而活动监视器那列是 footprint （含压缩与图形内存）——WindowServer 这类进程在活动监视器里会排得更高，看「谁占得最多」 以排序为准，两边的绝对值不会逐字节一致；列表里只放**可执行文件名**—— macOS 的 `ps comm` 给的是完整路径，超长时**保留尾部**（`…/Contents/MacOS/Google Chrome`）再取 basename，所以卡片上永远是能认的文件名、不会变成 `Ecosystem.fra…` 这种路径碎片；进程那列在宽档里 最宽（1.45fr），完整路径与 pid 在悬停提示里；列头 `CPU ▼` / `内存` 两格与下面两列数据**共用同一对 宽度变量**，右边缘严格对齐）。本地会话的 `%CPU` 是**瞬时值**：`ps` 的 `%cpu` 在 macOS 上是最长 一分钟的**衰减平均**（man ps 原话），和活动监视器那种瞬时采样本来就对不上；所以宿主只取 `ps` 的 **累计 CPU 时间**，两次采样（5 秒）做差除以墙钟——首见 / 计数器回退 / 间隔过短时退回 `ps` 的均值。 SSH 远端的 awk 仍用远端 `ps` 的 `%cpu`（采集在远端、没有本地那两次采样的上下文）。 **点列头切排序**：同一列再点翻方向（▼ 降序 / ▲ 升序），换列回到该列降序，脚注与活跃列跟着变 （排序只在内存里，重开浮层回到 CPU 降序）。放不下的进程名做**首尾截断**（`com.appl…ontent (Renderer)`）：纯尾部省略号会把一串同前缀的 XPC 服务截成同一个 `com.apple.WebK…`，认不出是谁， 而首尾截断保住能区分的括号后缀——宽度用 Canvas `measureText` 按当前字体实量，尾部最多占 70%。 展开后**那颗箭头翻成 ▴**（tooltip / `aria-label` 一起换成「收起」），否则形状不变、点了会像没反应；Esc / 点卡片外面收起， 箭头转回 ▾。三格**等高**（主体 96px）： 脚注那一行锁死 16px 且不被 flex 压缩——`overflow: hidden` 是为了横向省略号，压扁时会把字的 下半截一起切掉。内容跟着每秒的 stats 帧刷新，客户端不额外发请求。每核与进程是帧里的**可选字段**（本地走 `os.cpus()` + `ps`，SSH 远端在常驻 awk 里顺带取）：旧版宿主 / Windows 远端 / 采不到时各自显示「暂无」。 **低频字段缺席的那几帧沿用上一帧**（远端 awk 把 `ps` 压到 5 拍一次，`cpuCores` 也要第二帧 才有差值窗口）——「这一帧没带」不等于「采不到」，否则进程卡片会每秒在列表与「暂无」之间 翻一次（`mergeOptionalStats`，单测 + preview 场景 `stats-sparse` 守）；合并只发生在 `cpuCores` / `procs` 两个键上，CPU / 内存 / 网速这些每秒实测量绝不沿用。
    
-   **两条采集路径，同一帧形状**：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 / ps）只在首次采样时等一次， 之后一律「用上一次的值 + 到点后台刷新」（本地是 `SLOW_TTL_MS = 5000` 的槽位，SSH 远端的 常驻循环里是 `REMOTE_SLOW_EVERY = 5` 拍的相位门）——某台机器某个命令卡住只会让那一格 显示几秒前的旧值，不会把「每秒一帧」的时间轴拖散（实测 3000ms/帧 → 3~12ms/帧）， 也不会每秒 fork 一个 `df` / `ps`（磁盘与 Top 进程 5 秒刷一次，中间沿用上次读数， 不会「有一下无一下」）。
    
-   **热生效**：关掉 `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 线）自动回退到侧边栏「插件」页里该行的「>」子页。

| 项 | 默认 | 说明 |
| --- | --- | --- |
| `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](https://npmcn.com/package/docs/architecture.md#7-%E4%B8%80%E6%9D%A1%E8%AF%B7%E6%B1%82%E7%BB%8F%E8%BF%87%E4%BB%80%E4%B9%88)） |
| `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 一并关闭），保存即热生效 |
| `terminalFontFamily` | `''` | 终端字体栈（0.34.0）：逗号分隔的 CSS 字体名，**排在内置等宽栈之前**（内置栈 JetBrains Mono → SF Mono → ui-monospace → …）。空串 = 只用内置栈。只影响渲染，不碰 PTY；值里的引号/反斜杠/尖括号会被清洗掉，每个名字会补上双引号（CSS 通用族除外）。改了即时重排已有终端 |
| `terminalFontSize` | 13 | 终端字号（px，8~32） |
| `terminalCursorStyle` | `bar` | 光标形态：`bar` / `block` / `underline`（0.34.0）。终端里的程序也写这个值（DECSCUSR `\e[N q`，zsh 的 zle 每次画提示符都写），所以本插件在每批输出解析完后把被改掉的纠回；**全屏程序（vim / less / htop / fzf，备用屏幕）让位** |
| `terminalCursorBlink` | true | 光标是否闪烁。同上：程序发的 `?12h` / `?12l` 会覆盖它，普通缓冲区里会被纠回，全屏程序让位 |
| `terminalLineHeight` | 1.35 | 行高倍数（1~2）。方块光标通常配 `1`：1.35 会把单元格撑高，光标比字形高出一截 |
| `sidebarHostAlign` | false | 侧栏「终端」入口与宿主 `.panelRow` 对齐（0.34.0）：宽度 `calc(100% - 4px)`、`margin 0 2px`、`min-height 36`、`padding 7px 8px`、14px 字号、16px 图标。默认 false = 插件自带尺寸（纯增量样式，不动默认观感） |

## 连接栏：连接身份与复制

SSH 标签的连接栏除了动作按钮，还把**这条连接的身份证**摆在同一行（全部只读，改连接仍走设置卡片）：

-   **地址可点**：左侧 `user@host:port` 是一枚可点的胶囊（鼠标点 / 键盘 Enter、空格都可）， 点开「连接详情」弹层：目标、连接簿条目、认证方式、跳板机、代理命令、**持久化状态** （tmux 已托管 / 请求了但 tmux 不可用）与**最近一条状态**（SSH 失败原因、退出码等）。 数据来自会话的 `spawnSpec` + 标签状态，不额外打接口。
-   **跳板机徽标**：配了跳板机时地址旁多一枚「跳板机」徽标，title 是完整的 `[user@]host[:port]`——走了 bastion 的连接和直连在界面上本来长得一模一样，排障时最容易看错。
-   **「⋯」里两个复制项**（**都不含任何凭据**）：
    -   **复制连接信息**：目标 / 条目 / 认证 / 跳板机 / 代理命令，每行 `标题: 值`；
    -   **复制 SSH 命令**：一条可直接粘进终端的命令，端口 / 跳板机 / 代理命令按 OpenSSH 的写法带上 （`ssh -p 2222 -J jump@bastion:2200 -o ProxyCommand='…' ops@host`）。密码、私钥路径、 passphrase **一律不进剪贴板**——那是「更方便地泄露」，不是「更方便地连接」。

## 连接栏扩展点（客户端服务 `ttyConnbar`，0.13.0）

其他插件可以在 SSH 连接栏（SFTP / 隧道按钮那一行）追加自己的上下文按钮，而不需要 tty 认识它——tty 只暴露一个通用客户端服务。**内置动作（重新打开 / SFTP / 隧道）也 走同一条注册通道**，显示顺序 = 注册顺序；未注册任何扩展时行为与之前完全一致。 唯一的例外是「**⋯ 更多**」：它是动作区的溢出入口，**恒排最后**（在所有第三方动作之后）， 免得新插件把「更多」顶到中间、看起来像只覆盖它前面那几个按钮。

```js
// 消费方（如 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`）。

```js
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](https://npmcn.com/package/docs/architecture.md#7-%E4%B8%80%E6%9D%A1%E8%AF%B7%E6%B1%82%E7%BB%8F%E8%BF%87%E4%BB%80%E4%B9%88)；docker 侧的同一句边界写在 它的 README「已知限制」。

### 就地嵌入终端（`ttyTerminal.mount`，0.15.0）

`open` 是「借 tty 的弹窗开一个标签」——用户的面板会被弹窗盖住/被顶到后面；如果消费方 希望**在自己的面板里就地放一块终端**（典型是 dsh-docker 的终端抽屉：看着容器日志直接 进容器敲命令，上下文不断），用 `mount`：

```js
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` 表示「不隶属任何 标签」（一直可见）——面板开着但一个标签都没有时自动落进这一档；消费方也可以用它声明 「这块与标签无关，别跟着切」。

```js
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 把日志交给会话之后自动折起终端，让用户直接看到会话，而不是对着一个 盖住会话的弹窗猜「点了没有反应」。返回值是调用后的最小化态，调用方据此决定提示文案里还要 不要写「会话在面板后面」。

```js
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}` | 面板尺寸变化。**客户端只在容器可见且尺寸可信时发**（D79：隐藏标签 / 未挂载 / 面板最小化一律不发，PTY 保留上一次有效尺寸）；宿主的夹紧只做安全下限，非数值（含 `null`）按非法值回落 80×24 而不是夹到下限 |
| 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 前强制冲刷保证帧序——**退出前最后一批一定发出**（D76：这一批曾被终局路径自己的 `closed` 守卫整批吞掉，于「打印完就退出」的进程上表现为面板永远少最后几行） |
| 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}` | PTY 退出事实（恰好一次；attach 换连接后仍随当前连接送达） |
| S→C | `{t:'error', sid?, m}` | 错误 |
| S→C | `{t:'sessions', list, tmux?}` | 会话快照（`{sid, kind, target, pid?, cwd, startedAt, lastOutputAt, attachable, persist?}`）；`tmux` = 专用 socket 上现存的持久会话名 + SSH 持久会话留存名（0.10.1，可观测字段） |

断线保活语义：客户端正常关面板会先逐个发 `kill` 再断开；因此「WS close 且仍有存活会话」判定为异常断开——会话转入孤儿状态（输出继续积累进环形 缓冲，不向任何连接发送），保活 `reconnectGraceSec` 后由回收器清理；期间 新连接可 `{t:'sessions'}` 查询 + `{t:'attach', sid}` 重连回放。

sid 省略时按「该连接唯一会话」路由；连接上存在 0 或多个会话时省略 sid 会报错。upgrade 路由带 loopback 信任围栏（remoteAddress + Host + Origin 校验），仅本机 Web GUI 可连。

## 开发

```bash
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 把 50 个界面状态逐个渲染并 截图到 `packages/tty/.preview/shots/`：

```bash
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 的回归）/ 最小化徽标 / 退出提示条与错误遮罩 / 「⋯」里的「清理已退出的标签」（含点下去之后）/ AI 释放会话后标签的去留 / 关面板二次确认 （有活会话 / 只剩已退出标签两种）/ 批量关标签（「⋯」的批量行、标签右键菜单、 「关闭其他」的确认层、中键关闭，0.30.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 起会警告一次并退化为固定尺寸， 不再逐帧抛错；**D79 补**：这里的判据是显式看 `typeof terminal.resize === 'function'` ——老写法 `handle.terminal?.resize?.(…)` 只在**抛错**时才进警告分支，DSH 若把 `handle.terminal` 改名 / 移除，既不 resize 也不警告（承诺落空、排查无线索）。
-   **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，0.25.0 起按 profile 区分：`tmuxSocketName()` = 无 profile 时 `dsh-tty`、有 profile 时 `dsh-tty-<profile>`）托管——宿主被硬杀 / 保活回收 / 浏览器丢失标签规格时，tmux 会话会**留存**（这正是恢复能力的前提），直到机器重启或手动 `tmux -L <socket> 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 <socket> kill-server` 让下次 spawn 重建 server；`<socket>` 就写在生成的 `tmux.conf` 注释里）；`grace=0` 的「断开立即结束」对持久标签同样会 kill-session（tmux 会话不存活）；持久 SSH 会话要求远程装有 tmux（未装自动降级普通会话，连接栏常驻「未持久化」标记），且远程 `~/.tmux.conf` 不影响专用 socket 的独立 conf（`-f /dev/null`）；SSH 持久会话名随 settings 留存；**两个窗口同时接回同一持久会话**时共享同一个宿主 PTY（0.10.1，单客户端扇出，名额不翻倍），两侧行数以最近调整尺寸的窗口为准（尺寸不一致时较大一侧由 onResize 自愈重画）。
-   **SFTP 边界**：文件权限 = 对应 SSH 账号的终端权限（无额外沙箱/chroot）；下载经浏览器内存（超大文件建议终端 `scp`/`rsync`）；agent 工具 `sftp_read` ≤1MB 且拒绝二进制、`sftp_write` 单次 ≤1MB（大内容走面板上传或终端）；`sftp_*` 工具只收连接簿条目名，内联凭证仅供面板对话框使用；覆盖写走同目录临时分片 `.dsh-part-<uuid>` + rename 原子落盘——宿主进程崩溃 / 断电时可能留下分片孤儿（下次向同目录覆盖上传时会自动清理超过 24h 的残留）。
-   **SSH host key 为 TOFU 钉扎**：首次连接自动记录 sha256 指纹（trust on first use），之后任一记录指纹匹配放行、全部不匹配拒绝——不再是无条件放行的 accept-and-log。注意 TOFU 的固有边界：首次连接若已遭遇 MITM 则记录的 就是伪指纹；`hostKeys` 随 settings 落盘，指纹变更需人工在设置卡片确认 并删除记录；同一主机的多把钥匙（rsa/ed25519/ecdsa）合并为一条记录的多 指纹集合（0.19.0），算法协商变化不再误报「指纹变更」；known\_hosts 导入 同样保留同主机的全部指纹。
-   **浏览器标签持久化不含明文凭证**（0.19.0）：SSH 标签写入 sessionStorage / localStorage 的规格副本会剥掉明文 `password` / `passphrase` （`env:` 引用保留），并打 `credsStripped` 标记——恢复/重开时终端里提示 重新输入。本会话内存里的规格不受影响。
-   **SSH 密码 / 口令建议 `env:VAR` 引用**：连接簿随 settings 文件落盘， `password` / `passphrase` 明文入库有泄露面；建议 `env:VAR` + dsh-env-manager 托管，或直接用 `agent` 认证（凭证不落盘）。
-   **服务器状态条（0.17.0）边界**：远端靠两跳兜底：先 POSIX（要 `/proc` + `awk`），一帧未出则回退 PowerShell——**Windows 远端**由此覆盖（温度多数组件没有、显示「无」，磁盘取系统盘 `%SystemDrive%`，TCP 走 `netstat -an`，网速走 `Get-NetAdapterStatistics` 差值；只输出整数，从根上避开区域小数点与科学计数法），**macOS/BSD 远端**两跳都不成立、一个字段都拿不到、状态条整条隐藏；非 Linux 远端的每次（重新）订阅会多一跳注定失败的 exec（约百毫秒，不影响出帧延迟）；Linux 上 CPU 温度只在暴露 `/sys/class/thermal` 的机器上有（多数云主机/虚拟机没有，显示「无」）；磁盘固定取 `/`（Windows 取系统盘）所在文件系统（不做多挂载点）；远端脚本经 `sh -c` 单引号包裹（登录 shell 是 fish/csh 也能跑）；本地会话的指标属于**宿主机器**（多个本地标签共享同一份采样，CPU/网速差值窗口连续），macOS 没有 /proc、`ss`、sysfs，改用 os/netstat/vm\_stat（网速那条是 `netstat -ibn`——**必须带 `-n`**：不带时 netstat 会对接口地址做反查，DNS 不响应就挂几十秒，D50 的根因就是它），故温度恒为「无」，内存按 vm\_stat 的 active+wired+compressed 计（os.freemem 会把文件缓存算成已用、长期 99%）；指标是**瞬时值**，没有历史曲线，也不提供 agent 工具（agent 侧仍用 tty\_\* 命令粒度能力）。
-   **SSH 会话没有本地 pid**：ssh2 shell channel 不是本机进程，`ready.pid` 为 `null`、`tty_list` 显示 `target` 而非 pid，本机 `ps` / `kill` 对远程 进程无效——关闭请用标签 ✕ 或 `kill` 帧（关闭的是 ssh2 channel）。

## 工作原理

```
浏览器半体 (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 分帧兜底）
  ├─ shel
```

## 常见问题

### @hyzyn/dsh-tty 是什么？

DSH Web GUI 的终端面板插件：侧边栏「终端」提供 xterm\.js \+ PTY 全交互终端与 ssh2 原生 SSH 远程连接（连接簿、主机指纹 TOFU、跳板机），支持 tmux 会话持久化、SFTP 文件传输、端口转发与服务器状态条，并向其他插件开放 ttyTerminal / ttyPanel / ttyConnbar 扩展点；Windows 宿主同样可用。

### 如何安装 @hyzyn/dsh-tty？

在项目目录运行 npm install @hyzyn/dsh-tty，或使用 pnpm add / yarn add / bun add。

### @hyzyn/dsh-tty 支持 TypeScript 吗？

支持。包内自带 TypeScript 类型声明（package\.json 声明了 types 入口），安装后即可获得类型提示，无需再安装 @types 包。

### @hyzyn/dsh-tty 支持 ESM 还是 CommonJS？

以 ES Module 形式发布，推荐使用 import 引入。在 CommonJS 项目中，Node\.js 20\.19\+ / 22\.12\+ 可以直接 require\(\) 不含顶层 await 的 ES Module，更早的版本需要改用动态 import\(\)。

### @hyzyn/dsh-tty 需要什么版本的 Node\.js？

package\.json 的 engines 字段要求 Node\.js \>=22\.19\.0。版本不符时 npm 默认只给出警告，开启 engine-strict 后会拒绝安装。

### @hyzyn/dsh-tty 可以商用吗？

@hyzyn/dsh-tty 使用 Apache-2\.0 许可。宽松许可：允许商业使用、修改和再分发，并附带明确的专利授权；分发时需保留许可与 NOTICE 文件，修改过的文件要注明改动。以上为概括，不构成法律意见，请以许可原文为准。

### @hyzyn/dsh-tty 还在维护吗？

最新版本 0\.36\.1 发布于 2026/11/01（2 天前）；至今共发布 55 个版本，其中过去 12 个月发布了 55 个；首次发布于 2026/08/24。发布频率只是参考信号：功能稳定的工具可能很少需要更新，建议结合源码仓库的 issue 和提交记录判断。

