@hyzyn/dsh-tty 0.19.3 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -120,7 +120,7 @@ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
120
120
  const REAPER_INTERVAL_MS = 10_000;
121
121
  /** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
122
122
  const STATS_INTERVAL_MS = 1000;
123
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
123
+ const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
124
124
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
125
125
  function wrapLocalPty(handle) {
126
126
  let resizeWarned = false;
@@ -803,6 +803,196 @@ function isLoopbackUpgrade(req) {
803
803
  return false;
804
804
  }
805
805
  }
806
+ /* ------------------------------------------------------------------ *
807
+ * 虚拟屏(xterm-headless)——构造与异常兜底(D57)
808
+ * ------------------------------------------------------------------ */
809
+ /**
810
+ * 虚拟屏的 scrollback 余量(D57)。
811
+ *
812
+ * **不能是 0。** xterm 的 `Buffer` 在 `scrollback: 0` 时把 `lines.maxLength` 压成
813
+ * `rows`,但 normal buffer 的 `_hasScrollback` 仍是 true(reflow 照常开启):输出与
814
+ * resize(列宽变化触发 reflow)交错时,`lines` 会短于 `ybase + y`,于是
815
+ * `lineFeed()` 里 `lines.get(ybase + y).isWrapped = false` 命中 `undefined` →
816
+ * 未捕获 `TypeError: Cannot set properties of undefined (setting 'isWrapped')`。
817
+ *
818
+ * 该异常抛在 `WriteBuffer._innerWrite` 的 `setTimeout` 回调里,写入路径的同步
819
+ * try/catch 结构性拦不住,会直接打死整个宿主进程(线上 `last-failure-web.log`
820
+ * 的堆栈即此)。
821
+ *
822
+ * 关键在 `lines.maxLength`(= rows + scrollback):`BufferService.scroll` 只在「没满」时
823
+ * 才 `lines.push` + `ybase++`(成对)。`scrollback: 0` 把 maxLength 钉死成 rows,
824
+ * 一旦有别的路径把 `ybase` 顶上去(resize 收缩 / reflow),`lines` 长度就再也追不上,
825
+ * `ybase + y + 1` 越界只是时间问题。留 1 行余量(maxLength = rows + 1)即维持住成对增长:
826
+ * 同一最小序列 `scrollback: 0` 崩 5/5,`scrollback: 1` 崩 0/5;700 块随机屏压测里
827
+ * `ybase` 涨到 16 也没再出现越界(见 test/screen-crash.test.ts)。
828
+ *
829
+ * 余量不影响 `tty_screen` 读数——它走 `buffer.getLine(row)`(内部 `ybase + row`,
830
+ * 即视口),多出来的行只在回滚区,不进读数。
831
+ */
832
+ export const SCREEN_SCROLLBACK = 1;
833
+ /**
834
+ * 建一块虚拟屏(`tty_screen` 的数据源);失败降级为 null。
835
+ *
836
+ * 导出仅供单测(test/screen-crash.test.ts)钉住构造参数——生产路径是
837
+ * `SessionManager.createScreen`,它必须与这里同源(就一行委托)。
838
+ */
839
+ export function createHeadlessScreen(cols, rows) {
840
+ try {
841
+ // buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
842
+ return new HeadlessTerminal({ cols, rows, scrollback: SCREEN_SCROLLBACK, allowProposedApi: true });
843
+ }
844
+ catch {
845
+ return null;
846
+ }
847
+ }
848
+ /** xterm-headless 的异常都带这个文件名(压缩产物的堆栈里也是它)。 */
849
+ const XTERM_SCREEN_CRASH_RE = /xterm-headless|@xterm\/headless/;
850
+ /** 累计吞掉的虚拟屏异常数(只增不减;排障可见 + 单测断言用)。 */
851
+ let screenCrashTotal = 0;
852
+ /** 读累计吞掉的虚拟屏异常数。 */
853
+ export function xtermScreenCrashCount() {
854
+ return screenCrashTotal;
855
+ }
856
+ /** 判定未捕获异常是否来自虚拟屏(xterm-headless)。导出仅供单测。 */
857
+ export function isXtermScreenCrash(err) {
858
+ const stack = err instanceof Error ? (err.stack ?? '') : String(err);
859
+ return XTERM_SCREEN_CRASH_RE.test(stack);
860
+ }
861
+ /**
862
+ * 记账并吞掉一个虚拟屏异常;返回 true 表示已吞(非虚拟屏异常返回 false,交回调用方)。
863
+ * 导出仅供单测。
864
+ */
865
+ export function swallowXtermScreenCrash(err) {
866
+ if (!isXtermScreenCrash(err))
867
+ return false;
868
+ screenCrashTotal++;
869
+ const message = err instanceof Error ? err.message : String(err);
870
+ console.warn(`[dsh-tty] 虚拟屏(xterm-headless)未捕获异常已吞掉,不影响宿主(累计 ${screenCrashTotal} 次):${message}`);
871
+ return true;
872
+ }
873
+ let xtermGuardRefs = 0;
874
+ let xtermGuardHandler;
875
+ let xtermGuardRejectionHandler;
876
+ /** 解绑兜底(引用计数归零才真正摘监听器)。 */
877
+ function releaseXtermScreenCrashGuard() {
878
+ if (xtermGuardRefs === 0)
879
+ return;
880
+ if (--xtermGuardRefs > 0)
881
+ return;
882
+ if (xtermGuardHandler !== undefined) {
883
+ process.off('uncaughtException', xtermGuardHandler);
884
+ xtermGuardHandler = undefined;
885
+ }
886
+ if (xtermGuardRejectionHandler !== undefined) {
887
+ process.off('unhandledRejection', xtermGuardRejectionHandler);
888
+ xtermGuardRejectionHandler = undefined;
889
+ }
890
+ }
891
+ /**
892
+ * 注册进程级虚拟屏异常兜底(D57):把来自 xterm-headless 的未捕获异常 / 未处理 rejection
893
+ * 吞掉并记账,让插件自己的 bug 不再拖垮整个 harness。幂等 + 引用计数,返回解绑函数。
894
+ *
895
+ * 覆盖两个入口:
896
+ * - `uncaughtException`:同步路径(`_innerWrite` 的定时器回调里抛出,见上);
897
+ * - `unhandledRejection`:xterm 的异步 handler(DCS/OSC)rejection 走这里,宿主实测
898
+ * **0 处**监听,Node 15+ 下未处理 rejection 直接杀进程。
899
+ *
900
+ * 三条边界(刻意如此,不是随手 `process.on`):
901
+ * 1. **只吞虚拟屏异常**——`isXtermScreenCrash` 按堆栈判定;其余异常照旧。
902
+ * 2. **其余异常只在「我们是唯一的监听者」时抛回**:没有本兜底时未捕获异常会让宿主退出,
903
+ * 抛回保住这个语义;已经有别的监听者(宿主/其它插件)时保持沉默,由它们决定——
904
+ * 此时抛回反而会抢在别人前面把进程杀掉。
905
+ * 3. `unhandledRejection` 的「抛回」还有一层必要性:**只要挂了监听器,Node 就不再走
906
+ * 默认的致命处理**,所以非虚拟屏的 rejection 必须由我们抛出来还原默认行为
907
+ * (已实测:抛回后进程照旧 exit 1)。
908
+ */
909
+ export function installXtermScreenCrashGuard() {
910
+ if (xtermGuardRefs++ > 0)
911
+ return releaseXtermScreenCrashGuard;
912
+ const onUncaught = (err) => {
913
+ if (swallowXtermScreenCrash(err))
914
+ return;
915
+ if (process.listenerCount('uncaughtException') <= 1)
916
+ throw err;
917
+ };
918
+ const onRejection = (reason) => {
919
+ if (swallowXtermScreenCrash(reason))
920
+ return;
921
+ if (process.listenerCount('unhandledRejection') <= 1)
922
+ throw reason;
923
+ };
924
+ xtermGuardHandler = onUncaught;
925
+ xtermGuardRejectionHandler = onRejection;
926
+ process.on('uncaughtException', onUncaught);
927
+ process.on('unhandledRejection', onRejection);
928
+ return releaseXtermScreenCrashGuard;
929
+ }
930
+ /* ------------------------------------------------------------------ *
931
+ * 虚拟屏心跳(D57 停摆检测)
932
+ * ------------------------------------------------------------------ */
933
+ /**
934
+ * 停摆判定窗口:写出去的数据超过这么久还没解析完,就认定那块屏的解析器已停摆。
935
+ * 正常屏的解析是毫秒级(回调随 `_innerWrite` 逐批回来),5s 不会误伤。
936
+ */
937
+ export const SCREEN_STALL_MS = 5000;
938
+ /** 退役原因①:写队列超限 / 尺寸非法导致的同步抛出。 */
939
+ export const SCREEN_DOWN_WRITE_REJECTED = '写入被拒(写队列超限或尺寸非法)';
940
+ /** 退役原因②:解析器停摆(超时窗口内没有任何一批数据被解析完)。 */
941
+ export const SCREEN_DOWN_STALLED = '解析停摆(xterm 在超时窗口内未回调)';
942
+ /** 建一份空心跳。 */
943
+ export function newScreenHeartbeat() {
944
+ return { inflight: 0, watchdog: null, lastParseAt: 0 };
945
+ }
946
+ /** 摘掉看门狗(会话结束 / 屏退役时调用,避免定时器在会话死后误报)。 */
947
+ export function clearScreenWatchdog(heartbeat) {
948
+ if (heartbeat.watchdog !== null) {
949
+ clearTimeout(heartbeat.watchdog);
950
+ heartbeat.watchdog = null;
951
+ }
952
+ }
953
+ /**
954
+ * 写一帧到虚拟屏,并维护停摆看门狗(D57)。
955
+ *
956
+ * **为什么需要心跳**:xterm 的解析在 `WriteBuffer._innerWrite` 的 setTimeout 回调里跑,
957
+ * 异常被进程级兜底吞掉之后,那块屏的解析器**永久停摆**——出错的那批数据留在写队列里、
958
+ * `_bufferOffset` 不前进,而 `write()` 只在队列**空**时才重新调度解析。后果:`tty_screen`
959
+ * 一直返回**冻结的旧画面**(agent 会据此行事),写队列还会一路堆到 5e7 字符上限。
960
+ * 心跳把这种屏识别出来退役,`tty_screen` 改为如实报「虚拟屏不可用」。
961
+ *
962
+ * 信号用 `write(data, cb)` 的回调(xterm 解析完这批数据才回调):停摆时回调永远不来 →
963
+ * `inflight` 不归零 → 看门狗判定。**不能用 `onWriteParsed` 事件**——它在 5.5.0 不是
964
+ * 公开 API(`Terminal` 只暴露 onBell/onBinary/onCursorMove/onData/onLineFeed/onResize/
965
+ * onScroll/onTitleChange)。
966
+ */
967
+ export function writeToScreen(screen, heartbeat, text, onStall, stallMs = SCREEN_STALL_MS) {
968
+ heartbeat.inflight++;
969
+ try {
970
+ screen.write(text, () => {
971
+ heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
972
+ heartbeat.lastParseAt = Date.now();
973
+ if (heartbeat.inflight === 0)
974
+ clearScreenWatchdog(heartbeat);
975
+ });
976
+ }
977
+ catch {
978
+ // 同步抛出(写队列超限 5e7 / 尺寸非法):屏已不可用,立刻判定停摆
979
+ heartbeat.inflight = Math.max(0, heartbeat.inflight - 1);
980
+ onStall(SCREEN_DOWN_WRITE_REJECTED);
981
+ return;
982
+ }
983
+ if (heartbeat.watchdog === null) {
984
+ const armedAt = Date.now();
985
+ heartbeat.watchdog = setTimeout(() => {
986
+ heartbeat.watchdog = null;
987
+ // 停摆要**双条件**:还有批次没解析完,且整个窗口内**毫无**解析进展。
988
+ // 只看 inflight 会误杀连续输出的健康屏——看门狗按首次写入武装、5s 后到期时,
989
+ // 活跃会话几乎总有在途批次(实测:6s 连续输出误判 1 次,见 DEFECTS D57)。
990
+ if (heartbeat.inflight > 0 && heartbeat.lastParseAt < armedAt)
991
+ onStall(SCREEN_DOWN_STALLED);
992
+ }, stallMs);
993
+ heartbeat.watchdog.unref?.();
994
+ }
995
+ }
806
996
  /* ------------------------------------------------------------------ *
807
997
  * 会话管理
808
998
  * ------------------------------------------------------------------ */
@@ -848,6 +1038,7 @@ export class SessionManager {
848
1038
  target: session.target,
849
1039
  startedAt: session.startedAt,
850
1040
  lastOutputAt: session.lastOutputAt,
1041
+ owner: session.owner,
851
1042
  ...(session.tmuxName !== null ? { persist: true } : {}),
852
1043
  };
853
1044
  return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
@@ -880,6 +1071,7 @@ export class SessionManager {
880
1071
  retire(session) {
881
1072
  session.closed = true;
882
1073
  this.sessions.delete(session.id);
1074
+ clearScreenWatchdog(session.screenHeartbeat);
883
1075
  try {
884
1076
  session.screen?.dispose();
885
1077
  }
@@ -905,10 +1097,16 @@ export class SessionManager {
905
1097
  * 回收孤儿会话(回收器定时调用):超过保活期的回收。graceMs<=0 时立即回收
906
1098
  * 全部孤儿——孤儿只在「断开瞬间 grace>0」时产生,热改 grace 为 0 不能只管
907
1099
  * 以后:已存在的孤儿会永久占 PTY 与名额,满额后新标签一直报「会话数已达上限」。
1100
+ *
1101
+ * agent 开的会话(owner:'agent')不走这条:它从出生起就没有客户端,判据
1102
+ * 「orphanedAt !== null」对它要么永不成立(不回收)要么被误当孤儿(一开就收)。
1103
+ * 它的关闭入口是 agent 的 tty_close 或用户在面板里接管后关标签。
908
1104
  */
909
1105
  async reapOrphans(graceMs) {
910
1106
  const now = Date.now();
911
1107
  for (const session of [...this.sessions.values()]) {
1108
+ if (session.owner === 'agent')
1109
+ continue;
912
1110
  if (session.orphanedAt === null)
913
1111
  continue;
914
1112
  if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
@@ -921,6 +1119,7 @@ export class SessionManager {
921
1119
  this.sessions.clear();
922
1120
  await Promise.all(all.map((session) => {
923
1121
  session.closed = true;
1122
+ clearScreenWatchdog(session.screenHeartbeat);
924
1123
  try {
925
1124
  session.screen?.dispose();
926
1125
  }
@@ -946,6 +1145,8 @@ export class TtyServer {
946
1145
  wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 });
947
1146
  /** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
948
1147
  pendingTmux = new Map();
1148
+ /** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
1149
+ panels = new Set();
949
1150
  /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
950
1151
  wsGateOpen = true;
951
1152
  /** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
@@ -1126,6 +1327,9 @@ export class TtyServer {
1126
1327
  }
1127
1328
  /** 帧只发给订阅了该会话的客户端(绑定键寻址,回帧带各连接自己的 sid,跨窗口共享也成立)。 */
1128
1329
  sendStats(session, frame) {
1330
+ // agent 侧 tty_stats 的数据源:不留档的话「没有面板订阅」的会话(agent 开的
1331
+ // 终端天然没有面板订阅)永远拿不到指标
1332
+ session.lastStats = frame;
1129
1333
  for (const bindingKey of session.statsSubs) {
1130
1334
  const client = session.clients.get(bindingKey);
1131
1335
  if (client === undefined)
@@ -1158,6 +1362,8 @@ export class TtyServer {
1158
1362
  const conn = { id: randomUUID(), open: true };
1159
1363
  /** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
1160
1364
  const local = new Map();
1365
+ this.panels.add(ws);
1366
+ ws.on('close', () => { this.panels.delete(ws); });
1161
1367
  const cleanupAll = async () => {
1162
1368
  const all = [...local.entries()];
1163
1369
  local.clear();
@@ -1250,6 +1456,145 @@ export class TtyServer {
1250
1456
  if (session.tmuxName !== null)
1251
1457
  void session.handle.tmuxRefresh?.();
1252
1458
  }
1459
+ /**
1460
+ * agent 开一个本地终端(tty_open 的实现)。
1461
+ *
1462
+ * 设计前提(与用户确认过):**开成面板里的普通会话,不做隐形会话** ——
1463
+ * 会话照常进 `sessions` 快照、面板能看见并接管、用户随时可以关。理由是
1464
+ * D06 那类「僵尸会话」正是隐形会话的产物:用户不知道机器上跑着什么。
1465
+ *
1466
+ * 与 `spawn` 帧的差别只有两处:没有 ws(clients 空表)、owner:'agent'
1467
+ * (逃过孤儿回收,见 reapOrphans)。
1468
+ */
1469
+ async openAgentSession(input) {
1470
+ if (!this.sessions.canSpawn()) {
1471
+ throw new Error(`会话数已达上限(${this.sessions.limitValue})——先在面板里关掉不用的标签,或调大「并发会话上限」`);
1472
+ }
1473
+ const cwd = typeof input.cwd === 'string' && input.cwd.trim() !== '' ? input.cwd.trim() : this.options.cwd;
1474
+ if (!existsSync(cwd))
1475
+ throw new Error(`cwd 不存在: ${cwd}`);
1476
+ const sid = randomUUID();
1477
+ const command = typeof input.command === 'string' && input.command.trim() !== '' ? input.command.trim() : null;
1478
+ // 命令型会话不做 tmux 持久化(命令短命,与 spawn 帧同规则)
1479
+ const persistName = command === null && typeof input.persistName === 'string' && input.persistName !== '' && this.options.persistence === 'tmux'
1480
+ ? sanitizePersistName(input.persistName, sid)
1481
+ : null;
1482
+ // 同 persistName 已有存活会话:直接复用(跨窗口共享同语义),不新建 PTY
1483
+ if (persistName !== null) {
1484
+ const existing = this.sessions.findByTmuxName(persistName);
1485
+ if (existing !== undefined)
1486
+ return { sid: existing.id, persist: true };
1487
+ }
1488
+ const { session, degraded } = await this.createLocalSession({
1489
+ sid,
1490
+ cols: input.cols,
1491
+ rows: input.rows,
1492
+ cwd,
1493
+ command,
1494
+ persistName,
1495
+ client: null, // agent 路径:无客户端
1496
+ local: new Map(),
1497
+ owner: 'agent',
1498
+ });
1499
+ // 面板可见性:新会话推给所有已连接的面板(客户端据此建「agent 开的」标签)
1500
+ this.broadcastSessions();
1501
+ return { sid: session.id, persist: session.tmuxName !== null && !degraded };
1502
+ }
1503
+ /** agent 关掉一个会话(tty_close 的实现):只允许关 agent 自己开的,用户标签不越权。 */
1504
+ async closeAgentSession(sid) {
1505
+ const session = this.sessions.get(sid);
1506
+ if (session === undefined || session.closed)
1507
+ throw new Error(`会话不存在或已结束: ${sid}`);
1508
+ if (session.owner !== 'agent') {
1509
+ throw new Error(`会话 ${sid} 是用户在面板里开的(owner=user):请在面板里关闭那个标签,不要由 agent 越权结束`);
1510
+ }
1511
+ this.flushPendingOutput(session);
1512
+ this.killSessionNow(session);
1513
+ this.broadcastSessions();
1514
+ return { ok: true };
1515
+ }
1516
+ /**
1517
+ * 把当前会话清单推给所有已连接面板(agent 开关会话后让面板即时反映)。
1518
+ *
1519
+ * 用自己登记的连接集合而不是 `this.wss.clients`:后者只在真实 WS 服务器
1520
+ * 接线时才有值(单测直接调 onConnection 时为空),且语义上我们要的是
1521
+ * 「已接线的面板连接」。
1522
+ */
1523
+ broadcastSessions() {
1524
+ const list = this.sessions.listForAttach();
1525
+ for (const ws of this.panels) {
1526
+ send(ws, { t: 'sessions', list, tmux: [] });
1527
+ }
1528
+ }
1529
+ /**
1530
+ * 取一次会话所在机器的指标(tty_stats 的实现)。
1531
+ *
1532
+ * 按需采样、不依赖面板是否订阅状态条:本地会话直接跑本地采样器;SSH 会话在
1533
+ * 同一连接上开一次性 exec channel 跑一帧脚本(statsExec 的常驻循环不适合
1534
+ * 一次性取数,故用 handle.statsExec 的单帧变体——没有的话返回最近留档)。
1535
+ * 失败不抛给 agent 的判断链:返回 available:false + 原因。
1536
+ */
1537
+ async sampleStats(session) {
1538
+ if (session.kind === 'local') {
1539
+ try {
1540
+ const frame = await localStatsSampler().sample();
1541
+ if (!hasStatsData(frame))
1542
+ return { available: false, reason: '本机未采到可用指标(平台不支持或字段全缺)' };
1543
+ session.lastStats = frame;
1544
+ return { available: true, frame };
1545
+ }
1546
+ catch (error) {
1547
+ return { available: false, reason: `本机采样失败: ${error instanceof Error ? error.message : String(error)}` };
1548
+ }
1549
+ }
1550
+ const statsExec = session.handle.statsExec;
1551
+ if (statsExec === undefined) {
1552
+ // 没有 exec 通道(或远端不支持):退回最近留档(面板订阅过就有)
1553
+ if (session.lastStats !== null)
1554
+ return { available: true, frame: session.lastStats };
1555
+ return { available: false, reason: '该 SSH 会话没有可用的采集通道,且没有历史留档' };
1556
+ }
1557
+ // 一次性取一帧:脚本是常驻循环,收到第一帧即 stop
1558
+ return await new Promise((resolve) => {
1559
+ let settled = false;
1560
+ let handle = null;
1561
+ const finish = (result) => {
1562
+ if (settled)
1563
+ return;
1564
+ settled = true;
1565
+ try {
1566
+ handle?.stop();
1567
+ }
1568
+ catch {
1569
+ /* 已停 */
1570
+ }
1571
+ resolve(result);
1572
+ };
1573
+ const timer = setTimeout(() => { finish({ available: false, reason: '远端采集超时(3s)' }); }, 3000);
1574
+ timer.unref?.();
1575
+ try {
1576
+ handle = statsExec(buildRemoteStatsCommand(), (line) => {
1577
+ const frame = parseStatsLine(line);
1578
+ if (frame === null)
1579
+ return;
1580
+ clearTimeout(timer);
1581
+ session.lastStats = frame;
1582
+ finish({ available: true, frame });
1583
+ }, () => {
1584
+ clearTimeout(timer);
1585
+ // 一帧未读就结束:远端可能非 POSIX(Windows 远端走 PowerShell 版)
1586
+ if (session.lastStats !== null)
1587
+ finish({ available: true, frame: session.lastStats });
1588
+ else
1589
+ finish({ available: false, reason: '远端采集通道结束且未产出数据' });
1590
+ });
1591
+ }
1592
+ catch (error) {
1593
+ clearTimeout(timer);
1594
+ finish({ available: false, reason: `远端采集启动失败: ${error instanceof Error ? error.message : String(error)}` });
1595
+ }
1596
+ });
1597
+ }
1253
1598
  /** 等待同 tmuxName 的在途创建完成;返回可重绑定的会话(null = 无在途/已失败)。 */
1254
1599
  async waitPendingTmux(tmuxName) {
1255
1600
  const inflight = this.pendingTmux.get(tmuxName);
@@ -1262,6 +1607,111 @@ export class TtyServer {
1262
1607
  return null;
1263
1608
  }
1264
1609
  }
1610
+ /**
1611
+ * 创建本地会话(0.20.0 抽出,供 WS `spawn` 帧与 agent `tty_open` 共用)。
1612
+ *
1613
+ * 与连接无关是这次抽出的全部意义:`spawn` 帧带一个 ws(用户开的标签要立刻
1614
+ * ready + 收输出),`tty_open` 没有 ws(agent 开的会话从出生起就没有客户端,
1615
+ * 靠 owner:'agent' 逃过孤儿回收)。两条路径共用同一套:
1616
+ * - tmux 持久化探测与资源准备(同 persistName 复用既有会话,名额不翻倍);
1617
+ * - cwd 校验、spawnPlan 组装、并发在途收敛(pendingTmux);
1618
+ * - 会话对象装配 + 输出下行挂载 + 退出收尾。
1619
+ *
1620
+ * 调用方负责:上限检查(canSpawn)、错误帧、ready/notice 的呈现。
1621
+ * `client` 为 null 时创建无客户端的会话(agent 路径)。
1622
+ */
1623
+ async createLocalSession(input) {
1624
+ const { sid, cols, rows, cwd, command, persistName, client, local, owner } = input;
1625
+ const subprocess = this.ctx.get('subprocess');
1626
+ if (subprocess === undefined)
1627
+ throw new Error('subprocess 服务不可用');
1628
+ const wantsPersist = persistName !== null;
1629
+ let spawnPlan = command !== null
1630
+ ? buildCommandSpawn(this.options.shell, this.options.term, this.options.colorTerm, command)
1631
+ : buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
1632
+ let tmuxName = null;
1633
+ let degraded = false;
1634
+ if (wantsPersist) {
1635
+ const probe = await probeTmux();
1636
+ if (probe.available) {
1637
+ tmuxName = persistName;
1638
+ ensureTmuxAssets({ shell: this.options.shell, colorTerm: this.options.colorTerm, shellIntegration: this.options.shellIntegration, passthrough: probe.passthrough });
1639
+ spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
1640
+ }
1641
+ else {
1642
+ degraded = true; // tmux 不在:降级普通会话,由调用方给灰字提示
1643
+ }
1644
+ }
1645
+ const create = (async () => {
1646
+ const handle = wrapLocalPty(await subprocess.spawnTerminal({
1647
+ argv: spawnPlan.argv,
1648
+ rows: clampInt(rows, 24, 2, 200),
1649
+ cols: clampInt(cols, 80, 2, 500),
1650
+ cwd,
1651
+ env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
1652
+ graceMs: 5000,
1653
+ }));
1654
+ if (tmuxName !== null) {
1655
+ handle.tmuxTeardown = () => killTmuxSession(tmuxName);
1656
+ handle.tmuxRefresh = () => refreshTmuxClient(tmuxName);
1657
+ }
1658
+ const next = {
1659
+ id: sid,
1660
+ handle,
1661
+ clients: new Map(),
1662
+ closed: false,
1663
+ paused: false,
1664
+ owner,
1665
+ cwd,
1666
+ kind: 'local',
1667
+ target: '',
1668
+ startedAt: Date.now(),
1669
+ lastOutputAt: Date.now(),
1670
+ lastInputAt: Date.now(),
1671
+ buffer: '',
1672
+ decoder: new StringDecoder('utf8'),
1673
+ screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
1674
+ screenHeartbeat: newScreenHeartbeat(),
1675
+ screenDownReason: null,
1676
+ orphanedAt: null,
1677
+ shellState: createShellState(),
1678
+ pendingOutput: '',
1679
+ flushTimer: null,
1680
+ tmuxName,
1681
+ statsSubs: new Set(),
1682
+ lastStats: null,
1683
+ stats: null,
1684
+ statsFailed: false,
1685
+ };
1686
+ // 绑定:有客户端才绑(agent 路径 client === null → 保持空表 = 无客户端会话)。
1687
+ // 空表但 owner:'agent',故不会被孤儿回收器当孤儿收掉。
1688
+ if (client !== null)
1689
+ next.clients.set(client.connId + ':' + sid, { ws: client.ws, sid });
1690
+ local.set(sid, next);
1691
+ this.sessions.add(next);
1692
+ // spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
1693
+ // 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
1694
+ // 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
1695
+ // 重连 attach 还被拒并谎报「会话已连接到其它窗口」。
1696
+ if (client !== null && (!client.ws.readyState || client.ws.readyState !== WebSocket.OPEN)) {
1697
+ next.clients.clear();
1698
+ next.orphanedAt = Date.now();
1699
+ }
1700
+ return next;
1701
+ })();
1702
+ if (tmuxName !== null) {
1703
+ const registered = create.catch(() => null);
1704
+ this.pendingTmux.set(tmuxName, registered);
1705
+ void registered.finally(() => {
1706
+ if (this.pendingTmux.get(tmuxName) === registered)
1707
+ this.pendingTmux.delete(tmuxName);
1708
+ });
1709
+ }
1710
+ const session = await create;
1711
+ this.attachOutput(session);
1712
+ this.watchDone(session, local);
1713
+ return { session, wantsPersist, degraded };
1714
+ }
1265
1715
  /**
1266
1716
  * 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
1267
1717
  * 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
@@ -1296,15 +1746,32 @@ export class TtyServer {
1296
1746
  }
1297
1747
  void forceKill(session.handle);
1298
1748
  }
1299
- /** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。 */
1749
+ /** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
1750
+ * 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
1300
1751
  createScreen(cols, rows) {
1752
+ return createHeadlessScreen(cols, rows);
1753
+ }
1754
+ /**
1755
+ * 退役一块**不可用**的虚拟屏(D57):解析停摆或写队列满时调用。
1756
+ *
1757
+ * 只摘虚拟屏,**不动会话**——PTY 还活着、浏览器面板照常收发(虚拟屏只是 `tty_screen`
1758
+ * 的数据源)。退役后 `tty_screen` 会如实报「虚拟屏不可用(原因)」,而不是返回冻结的
1759
+ * 旧画面让 agent 据此行事。
1760
+ */
1761
+ dropScreen(session, reason) {
1762
+ if (session.screen === null)
1763
+ return;
1764
+ const screen = session.screen;
1765
+ session.screen = null;
1766
+ session.screenDownReason = reason;
1767
+ clearScreenWatchdog(session.screenHeartbeat);
1301
1768
  try {
1302
- // buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
1303
- return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
1769
+ screen.dispose();
1304
1770
  }
1305
1771
  catch {
1306
- return null;
1772
+ /* 已释放 */
1307
1773
  }
1774
+ console.warn(`[dsh-tty] 虚拟屏已退役(${reason}),会话 ${session.id} 的 tty_screen 将报不可用;PTY 与前端不受影响`);
1308
1775
  }
1309
1776
  async handleMessage(ws, msg, local, cleanupAll, conn) {
1310
1777
  try {
@@ -1357,91 +1824,33 @@ export class TtyServer {
1357
1824
  send(ws, { t: 'error', sid, m: `cwd 不存在: ${cwd}` });
1358
1825
  return;
1359
1826
  }
1360
- const subprocess = this.ctx.get('subprocess');
1361
- if (subprocess === undefined) {
1362
- send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
1363
- return;
1364
- }
1365
- const wantsPersist = persistName !== null;
1366
- let spawnPlan = command !== null
1367
- ? buildCommandSpawn(this.options.shell, this.options.term, this.options.colorTerm, command)
1368
- : buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
1369
- let tmuxName = null;
1370
- if (wantsPersist) {
1371
- const probe = await probeTmux();
1372
- if (probe.available) {
1373
- tmuxName = persistName;
1374
- ensureTmuxAssets({ shell: this.options.shell, colorTerm: this.options.colorTerm, shellIntegration: this.options.shellIntegration, passthrough: probe.passthrough });
1375
- spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
1376
- }
1377
- }
1378
- // 在途注册:tmuxName 相同的并发 spawn 等本次创建完成后重绑定(防竞态翻倍)
1379
- const create = (async () => {
1380
- const handle = wrapLocalPty(await subprocess.spawnTerminal({
1381
- argv: spawnPlan.argv,
1382
- rows: clampInt(msg.rows, 24, 2, 200),
1383
- cols: clampInt(msg.cols, 80, 2, 500),
1384
- cwd,
1385
- env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
1386
- graceMs: 5000,
1387
- }));
1388
- if (tmuxName !== null) {
1389
- handle.tmuxTeardown = () => killTmuxSession(tmuxName);
1390
- handle.tmuxRefresh = () => refreshTmuxClient(tmuxName);
1391
- }
1392
- const next = {
1393
- id: sid,
1394
- handle,
1395
- clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
1396
- closed: false,
1397
- paused: false,
1827
+ // 会话创建走共用工厂(与 agent tty_open 同一套);用户开的标签带连接,
1828
+ // 立刻 ready + 收输出
1829
+ let created;
1830
+ try {
1831
+ created = await this.createLocalSession({
1832
+ sid,
1833
+ cols: msg.cols,
1834
+ rows: msg.rows,
1398
1835
  cwd,
1399
- kind: 'local',
1400
- target: '',
1401
- startedAt: Date.now(),
1402
- lastOutputAt: Date.now(),
1403
- lastInputAt: Date.now(),
1404
- buffer: '',
1405
- decoder: new StringDecoder('utf8'),
1406
- screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
1407
- orphanedAt: null,
1408
- shellState: createShellState(),
1409
- pendingOutput: '',
1410
- flushTimer: null,
1411
- tmuxName,
1412
- statsSubs: new Set(),
1413
- stats: null,
1414
- statsFailed: false,
1415
- };
1416
- local.set(sid, next);
1417
- this.sessions.add(next);
1418
- // spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
1419
- // 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
1420
- // 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
1421
- // 重连 attach 还被拒并谎报「会话已连接到其它窗口」。
1422
- if (!conn.open || ws.readyState !== WebSocket.OPEN) {
1423
- next.clients.clear();
1424
- next.orphanedAt = Date.now();
1425
- }
1426
- return next;
1427
- })();
1428
- if (tmuxName !== null) {
1429
- const registered = create.catch(() => null);
1430
- this.pendingTmux.set(tmuxName, registered);
1431
- void registered.finally(() => {
1432
- if (this.pendingTmux.get(tmuxName) === registered)
1433
- this.pendingTmux.delete(tmuxName);
1836
+ command,
1837
+ persistName,
1838
+ client: { ws, connId: conn.id },
1839
+ local,
1840
+ owner: 'user',
1434
1841
  });
1435
1842
  }
1436
- const next = await create;
1437
- send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(tmuxName !== null ? { persist: true } : {}) });
1438
- if (wantsPersist && tmuxName === null) {
1843
+ catch (error) {
1844
+ send(ws, { t: 'error', sid, m: error instanceof Error ? error.message : String(error) });
1845
+ return;
1846
+ }
1847
+ const next = created.session;
1848
+ send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
1849
+ if (created.wantsPersist && created.degraded) {
1439
1850
  const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
1440
1851
  next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
1441
1852
  send(ws, { t: 'data', sid, d: notice });
1442
1853
  }
1443
- this.attachOutput(next);
1444
- this.watchDone(next, local);
1445
1854
  }
1446
1855
  else if (msg.t === 'ssh') {
1447
1856
  const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
@@ -1515,6 +1924,7 @@ export class TtyServer {
1515
1924
  clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
1516
1925
  closed: false,
1517
1926
  paused: false,
1927
+ owner: 'user',
1518
1928
  cwd: '',
1519
1929
  kind: 'ssh',
1520
1930
  target,
@@ -1524,12 +1934,15 @@ export class TtyServer {
1524
1934
  buffer: '',
1525
1935
  decoder: new StringDecoder('utf8'),
1526
1936
  screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
1937
+ screenHeartbeat: newScreenHeartbeat(),
1938
+ screenDownReason: null,
1527
1939
  orphanedAt: null,
1528
1940
  shellState: createShellState(),
1529
1941
  pendingOutput: '',
1530
1942
  flushTimer: null,
1531
1943
  tmuxName,
1532
1944
  statsSubs: new Set(),
1945
+ lastStats: null,
1533
1946
  stats: null,
1534
1947
  statsFailed: false,
1535
1948
  };
@@ -1728,6 +2141,7 @@ export class TtyServer {
1728
2141
  this.sessions.remove(session.id);
1729
2142
  if (session.kind === 'ssh' && session.tmuxName !== null)
1730
2143
  this.trackPersist(session.tmuxName, false);
2144
+ clearScreenWatchdog(session.screenHeartbeat);
1731
2145
  try {
1732
2146
  session.screen?.dispose();
1733
2147
  }
@@ -1778,11 +2192,12 @@ export class TtyServer {
1778
2192
  session.lastOutputAt = Date.now();
1779
2193
  session.buffer = tailFromSafeBoundary(session.buffer + text, BUFFER_CAP);
1780
2194
  feedShellIntegration(session, text);
1781
- try {
1782
- session.screen?.write(text);
1783
- }
1784
- catch {
1785
- /* 虚拟屏异常不阻断输出链路 */
2195
+ const screen = session.screen;
2196
+ if (screen !== null) {
2197
+ // 心跳包裹(D57):同步抛出 / 解析停摆的屏会被退役,而不是让 tty_screen 一直返回冻结画面
2198
+ writeToScreen(screen, session.screenHeartbeat, text, (reason) => {
2199
+ this.dropScreen(session, reason);
2200
+ });
1786
2201
  }
1787
2202
  if (session.clients.size === 0)
1788
2203
  return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
@@ -2054,6 +2469,18 @@ function remoteBasename(path) {
2054
2469
  return base === '' ? 'download' : base;
2055
2470
  }
2056
2471
  /** 人类可读文件大小(sftp_list render 用)。 */
2472
+ /** 人类可读时长(tty_stats 的「在线时长」用)。 */
2473
+ function humanDuration(seconds) {
2474
+ const s = Math.max(0, Math.floor(seconds));
2475
+ const d = Math.floor(s / 86400);
2476
+ const h = Math.floor((s % 86400) / 3600);
2477
+ const m = Math.floor((s % 3600) / 60);
2478
+ if (d > 0)
2479
+ return `${String(d)} 天 ${String(h)} 小时`;
2480
+ if (h > 0)
2481
+ return `${String(h)} 小时 ${String(m)} 分`;
2482
+ return `${String(m)} 分`;
2483
+ }
2057
2484
  function humanFileSize(bytes) {
2058
2485
  if (!Number.isFinite(bytes) || bytes <= 0)
2059
2486
  return '0 B';
@@ -2968,7 +3395,7 @@ const plugin = definePlugin({
2968
3395
  };
2969
3396
  }, 'dsh-tty: settings');
2970
3397
  });
2971
- // agent 工具集(P1):tty_list / tty_capture / tty_send。
3398
+ // agent 工具集(P1):tty_list / tty_open / tty_close / tty_stats / tty_capture / tty_send …
2972
3399
  // 信任模型:与 bash 工具同权(agent 本就能执行任意命令),不额外加确认层;
2973
3400
  // agent 对终端的操作会实时出现在浏览器面板里(同一 PTY),天然可被用户观察。
2974
3401
  // inject: ['tools'] 声明后(见上方),ctx.get('tools') 才能解析到服务。
@@ -3021,6 +3448,7 @@ const plugin = definePlugin({
3021
3448
  startedAt: { type: 'number', required: true },
3022
3449
  lastOutputAt: { type: 'number', required: true },
3023
3450
  persist: { type: 'boolean' },
3451
+ owner: { type: 'string', required: true },
3024
3452
  },
3025
3453
  },
3026
3454
  },
@@ -3029,11 +3457,12 @@ const plugin = definePlugin({
3029
3457
  render: (_args, value) => {
3030
3458
  const sessions = value?.sessions ?? [];
3031
3459
  const text = sessions.length === 0
3032
- ? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
3460
+ ? '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
3033
3461
  : '终端面板会话:' + sessions.map((s) => {
3034
3462
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
3035
3463
  const persist = s.persist === true ? ' [tmux 持久]' : '';
3036
- return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
3464
+ const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
3465
+ return `\n- sid=${s.sid} [${s.kind}]${owner}${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
3037
3466
  }).join('');
3038
3467
  return [{ type: 'text', text }];
3039
3468
  },
@@ -3042,6 +3471,126 @@ const plugin = definePlugin({
3042
3471
  return { sessions: sessions.list() };
3043
3472
  },
3044
3473
  })));
3474
+ activeDisposers.push(tools.register(defineTool({
3475
+ name: 'tty_open',
3476
+ description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条长驻命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。cwd 缺省为插件配置的工作目录。',
3477
+ parameters: {
3478
+ cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
3479
+ command: { type: 'string', description: '直接执行的命令(非交互);给出时不做 tmux 持久化。缺省 = 交互式 shell' },
3480
+ persistName: { type: 'string', description: 'tmux 持久会话名(开启「会话持久化」时有效;同名复用既有会话)。适合宿主重启后仍需存活的长任务' },
3481
+ cols: { type: 'number', description: '列数(2~500,默认 80)' },
3482
+ rows: { type: 'number', description: '行数(2~200,默认 24)' },
3483
+ },
3484
+ output: {
3485
+ schema: {
3486
+ type: 'object',
3487
+ additionalProperties: false,
3488
+ properties: {
3489
+ sid: { type: 'string', required: true },
3490
+ persist: { type: 'boolean', required: true },
3491
+ },
3492
+ },
3493
+ render: (_args, value) => {
3494
+ const v = value;
3495
+ return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。` }];
3496
+ },
3497
+ },
3498
+ async execute(args) {
3499
+ const input = args;
3500
+ return await server.openAgentSession({
3501
+ ...(typeof input.cwd === 'string' ? { cwd: input.cwd } : {}),
3502
+ ...(typeof input.command === 'string' ? { command: input.command } : {}),
3503
+ ...(typeof input.persistName === 'string' ? { persistName: input.persistName } : {}),
3504
+ cols: input.cols,
3505
+ rows: input.rows,
3506
+ });
3507
+ },
3508
+ })));
3509
+ activeDisposers.push(tools.register(defineTool({
3510
+ name: 'tty_close',
3511
+ description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。',
3512
+ parameters: {
3513
+ sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
3514
+ },
3515
+ output: {
3516
+ schema: {
3517
+ type: 'object',
3518
+ additionalProperties: false,
3519
+ properties: { ok: { type: 'boolean', required: true } },
3520
+ },
3521
+ render: (_args, value) => {
3522
+ const v = value;
3523
+ return [{ type: 'text', text: v.ok === true ? '会话已关闭' : '会话未能关闭' }];
3524
+ },
3525
+ },
3526
+ async execute(args) {
3527
+ const input = args;
3528
+ if (typeof input.sid !== 'string' || input.sid === '')
3529
+ throw new Error('sid 必须是非空字符串');
3530
+ return await server.closeAgentSession(input.sid);
3531
+ },
3532
+ })));
3533
+ activeDisposers.push(tools.register(defineTool({
3534
+ name: 'tty_stats',
3535
+ description: '读取某个终端会话所在机器的实时指标(CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长)——本地会话取宿主机,SSH 会话取那台远程主机(另开一条非 PTY 通道,不影响终端)。部署、压测、排查「机器是不是满了」之前先看它。仅 Linux 远端字段齐全,Windows 远端部分字段可采,macOS/BSD 远端取不到。',
3536
+ parameters: {
3537
+ sid: { type: 'string', required: true, description: '会话 id(tty_list 提供)' },
3538
+ },
3539
+ output: {
3540
+ schema: {
3541
+ type: 'object',
3542
+ additionalProperties: false,
3543
+ properties: {
3544
+ sid: { type: 'string', required: true },
3545
+ available: { type: 'boolean', required: true },
3546
+ reason: { type: 'string' },
3547
+ target: { type: 'string' },
3548
+ cpuPct: { type: 'number' },
3549
+ cores: { type: 'number' },
3550
+ memPct: { type: 'number' },
3551
+ memUsed: { type: 'number' },
3552
+ memTotal: { type: 'number' },
3553
+ diskPct: { type: 'number' },
3554
+ diskUsed: { type: 'number' },
3555
+ diskTotal: { type: 'number' },
3556
+ tcpConns: { type: 'number' },
3557
+ rxRate: { type: 'number' },
3558
+ txRate: { type: 'number' },
3559
+ tempC: { type: 'number' },
3560
+ uptimeSec: { type: 'number' },
3561
+ },
3562
+ },
3563
+ render: (_args, value) => {
3564
+ const v = value;
3565
+ if (v.available !== true)
3566
+ return [{ type: 'text', text: `会话 ${v.sid ?? '?'} 取不到指标:${v.reason ?? '未知原因'}` }];
3567
+ const parts = [
3568
+ v.cpuPct !== undefined ? `CPU ${v.cpuPct.toFixed(0)}%${v.cores !== undefined ? `(${String(v.cores)} 核)` : ''}` : null,
3569
+ v.memPct !== undefined ? `内存 ${v.memPct.toFixed(0)}%${v.memUsed !== undefined && v.memTotal !== undefined ? `(${humanFileSize(v.memUsed)} / ${humanFileSize(v.memTotal)})` : ''}` : null,
3570
+ v.diskPct !== undefined ? `磁盘 ${v.diskPct.toFixed(0)}%${v.diskUsed !== undefined && v.diskTotal !== undefined ? `(${humanFileSize(v.diskUsed)} / ${humanFileSize(v.diskTotal)})` : ''}` : null,
3571
+ v.tcpConns !== undefined ? `TCP 连接 ${String(v.tcpConns)}` : null,
3572
+ v.rxRate !== undefined ? `网速 ↓${humanFileSize(v.rxRate)}/s ↑${humanFileSize(v.txRate ?? 0)}/s` : null,
3573
+ v.tempC !== undefined ? `温度 ${v.tempC.toFixed(0)}°C` : null,
3574
+ v.uptimeSec !== undefined ? `在线 ${humanDuration(v.uptimeSec)}` : null,
3575
+ ].filter((x) => x !== null);
3576
+ const head = `会话 ${v.sid ?? '?'}${v.target !== undefined && v.target !== '' ? `(${v.target})` : ''} 指标:`;
3577
+ return [{ type: 'text', text: head + (parts.length > 0 ? parts.join(' · ') : '(无可用字段)') }];
3578
+ },
3579
+ },
3580
+ async execute(args) {
3581
+ const input = args;
3582
+ if (typeof input.sid !== 'string' || input.sid === '')
3583
+ throw new Error('sid 必须是非空字符串');
3584
+ const session = sessions.get(input.sid);
3585
+ if (session === undefined || session.closed)
3586
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
3587
+ const result = await server.sampleStats(session);
3588
+ if (result.available !== true || result.frame === undefined) {
3589
+ return { sid: input.sid, available: false, reason: result.reason ?? '未知原因' };
3590
+ }
3591
+ return { sid: input.sid, available: true, ...(session.target !== '' ? { target: session.target } : {}), ...result.frame };
3592
+ },
3593
+ })));
3045
3594
  activeDisposers.push(tools.register(defineTool({
3046
3595
  name: 'tty_capture',
3047
3596
  description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。',
@@ -3096,8 +3645,10 @@ const plugin = definePlugin({
3096
3645
  throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
3097
3646
  }
3098
3647
  // 保尾截断:last.output 本身已是环形保尾(COMMAND_CAP),这里再
3099
- // 收一刀也保尾——最近输出才是 agent 要的
3100
- return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
3648
+ // 收一刀也保尾——最近输出才是 agent 要的。
3649
+ // exitCode 用「键不存在」表达缺失(`?? undefined` 会留下一个
3650
+ // undefined 键,不是无损 JSON 值,宿主输出校验会判工具错,见 B33)。
3651
+ return { sid: input.sid, source: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
3101
3652
  }
3102
3653
  const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
3103
3654
  const rawTail = tailLines(session, lines);
@@ -3134,8 +3685,11 @@ const plugin = definePlugin({
3134
3685
  if (session === undefined || session.closed)
3135
3686
  throw new Error(`会话不存在或已退出: ${input.sid}`);
3136
3687
  const screen = session.screen;
3137
- if (screen === null)
3138
- throw new Error(`虚拟屏不可用: ${input.sid}`);
3688
+ if (screen === null) {
3689
+ // D57:屏可能被退役(解析停摆 / 写队列满)——如实报原因,别让 agent 以为只是没开
3690
+ const why = session.screenDownReason === null ? '' : `(${session.screenDownReason})`;
3691
+ throw new Error(`虚拟屏不可用: ${input.sid}${why}`);
3692
+ }
3139
3693
  const buffer = screen.buffer.active;
3140
3694
  const lines = [];
3141
3695
  for (let row = 0; row < screen.rows; row++) {
@@ -3226,7 +3780,7 @@ const plugin = definePlugin({
3226
3780
  // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
3227
3781
  const state = session.shellState;
3228
3782
  if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
3229
- finish({ matched: false, timedOut: false, exitCode: state.lastCommand.exitCode ?? undefined, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
3783
+ finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
3230
3784
  }
3231
3785
  };
3232
3786
  const timer = setTimeout(() => {
@@ -3319,7 +3873,10 @@ const plugin = definePlugin({
3319
3873
  async execute() {
3320
3874
  // 显式挑字段(0.19.0):list() 还带 enabled / lastForwardError,
3321
3875
  // 整包展开会突破 schema 的 additionalProperties:false——PTC 生成的
3322
- // TS 类型会漏字段
3876
+ // TS 类型会漏字段。
3877
+ // error 必须是「**键不存在**」而不是「键存在但值为 undefined」:后者
3878
+ // 保留了一个 undefined,不是无损 JSON 值,宿主校验会判
3879
+ // 「must be a lossless JSON object」直接把工具调用变成 Error(B33 抓到)。
3323
3880
  return {
3324
3881
  tunnels: tunnelManager.list().map((t) => ({
3325
3882
  name: t.name,
@@ -3327,7 +3884,7 @@ const plugin = definePlugin({
3327
3884
  direction: t.direction,
3328
3885
  rule: t.rule,
3329
3886
  state: t.state,
3330
- error: t.error ?? undefined,
3887
+ ...(t.error === null || t.error === undefined ? {} : { error: t.error }),
3331
3888
  connections: t.connections,
3332
3889
  totalConnections: t.totalConnections,
3333
3890
  })),
@@ -3676,7 +4233,7 @@ const plugin = definePlugin({
3676
4233
  },
3677
4234
  })));
3678
4235
  stateRef.toolsRegistered = true;
3679
- console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
4236
+ console.log('[dsh-tty] agent tools registered (tty_list, tty_open, tty_close, tty_stats, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list, sftp_list, sftp_read, sftp_write, sftp_mkdir, sftp_rename, sftp_remove, sftp_tree)');
3680
4237
  };
3681
4238
  refreshToolsHook = registerAll;
3682
4239
  registerAll();
@@ -3731,10 +4288,11 @@ const plugin = definePlugin({
3731
4288
  text: () => {
3732
4289
  const list = sessions.list();
3733
4290
  if (list.length === 0)
3734
- return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
3735
- return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
4291
+ return '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。';
4292
+ return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + list.map((s) => {
3736
4293
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
3737
- return `- sid=${s.sid} [${s.kind}]${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
4294
+ const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
4295
+ return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
3738
4296
  }).join('\n');
3739
4297
  },
3740
4298
  });
@@ -3770,6 +4328,10 @@ const plugin = definePlugin({
3770
4328
  }, REAPER_INTERVAL_MS);
3771
4329
  reaperTimer.unref?.();
3772
4330
  ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
4331
+ // 虚拟屏异常兜底(D57):xterm-headless 的解析跑在 WriteBuffer 的 setTimeout
4332
+ // 回调里,写入路径的同步 try/catch 结构性拦不住;没有兜底时任何一处虚拟屏异常
4333
+ // 都会直接打死宿主进程(Web GUI 掉线、会话表清空、agent 全丢)。插件卸载时摘掉。
4334
+ ctx.effect(() => installXtermScreenCrashGuard(), 'dsh-tty: xterm crash guard');
3773
4335
  // 插件卸载时回收全部会话、隧道与 SFTP 连接
3774
4336
  ctx.effect(() => {
3775
4337
  return () => {