hyzyn/dsh-plugin-kit

tty

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

UI 增强codegraphdeepseek-harnessdsh

@hyzyn/dsh-tty

中文 | English

DSH 侧边栏「终端」面板:xterm.js + 真实 PTY 的完整终端,本地与 SSH 一视同仁,长任务可断线保活。

特性

  • 真 PTY + WebGL 渲染:node-pty 真实 PTY,vim / htop / dev server 等 TUI 均可运行;多标签页。
  • 可选 tmux 会话持久化:宿主重启 / 网络抖动后重开即恢复现场;docker exec 这类「命令标签」自动重开。
  • ssh2 原生连接:agent forwarding + 主机指纹 TOFU 钉扎,连接簿统一管理;另有 SFTP 上传下载与 端口转发(-L / -R,断线自动重连)。
  • agent 侧按「命令」粒度取数:shell 集成(OSC 133/7)让 tty_capture{last} / tty_expect 拿到「上一条命令」的输出与退出码,而不是抓屏猜;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% 可用);

  1. 启动计划是 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 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。

典型 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 量级)。