@hyzyn/dsh-tty 0.19.3 → 0.20.0

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;
@@ -848,6 +848,7 @@ export class SessionManager {
848
848
  target: session.target,
849
849
  startedAt: session.startedAt,
850
850
  lastOutputAt: session.lastOutputAt,
851
+ owner: session.owner,
851
852
  ...(session.tmuxName !== null ? { persist: true } : {}),
852
853
  };
853
854
  return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
@@ -905,10 +906,16 @@ export class SessionManager {
905
906
  * 回收孤儿会话(回收器定时调用):超过保活期的回收。graceMs<=0 时立即回收
906
907
  * 全部孤儿——孤儿只在「断开瞬间 grace>0」时产生,热改 grace 为 0 不能只管
907
908
  * 以后:已存在的孤儿会永久占 PTY 与名额,满额后新标签一直报「会话数已达上限」。
909
+ *
910
+ * agent 开的会话(owner:'agent')不走这条:它从出生起就没有客户端,判据
911
+ * 「orphanedAt !== null」对它要么永不成立(不回收)要么被误当孤儿(一开就收)。
912
+ * 它的关闭入口是 agent 的 tty_close 或用户在面板里接管后关标签。
908
913
  */
909
914
  async reapOrphans(graceMs) {
910
915
  const now = Date.now();
911
916
  for (const session of [...this.sessions.values()]) {
917
+ if (session.owner === 'agent')
918
+ continue;
912
919
  if (session.orphanedAt === null)
913
920
  continue;
914
921
  if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
@@ -946,6 +953,8 @@ export class TtyServer {
946
953
  wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 * 1024 });
947
954
  /** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
948
955
  pendingTmux = new Map();
956
+ /** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
957
+ panels = new Set();
949
958
  /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
950
959
  wsGateOpen = true;
951
960
  /** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
@@ -1126,6 +1135,9 @@ export class TtyServer {
1126
1135
  }
1127
1136
  /** 帧只发给订阅了该会话的客户端(绑定键寻址,回帧带各连接自己的 sid,跨窗口共享也成立)。 */
1128
1137
  sendStats(session, frame) {
1138
+ // agent 侧 tty_stats 的数据源:不留档的话「没有面板订阅」的会话(agent 开的
1139
+ // 终端天然没有面板订阅)永远拿不到指标
1140
+ session.lastStats = frame;
1129
1141
  for (const bindingKey of session.statsSubs) {
1130
1142
  const client = session.clients.get(bindingKey);
1131
1143
  if (client === undefined)
@@ -1158,6 +1170,8 @@ export class TtyServer {
1158
1170
  const conn = { id: randomUUID(), open: true };
1159
1171
  /** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
1160
1172
  const local = new Map();
1173
+ this.panels.add(ws);
1174
+ ws.on('close', () => { this.panels.delete(ws); });
1161
1175
  const cleanupAll = async () => {
1162
1176
  const all = [...local.entries()];
1163
1177
  local.clear();
@@ -1250,6 +1264,145 @@ export class TtyServer {
1250
1264
  if (session.tmuxName !== null)
1251
1265
  void session.handle.tmuxRefresh?.();
1252
1266
  }
1267
+ /**
1268
+ * agent 开一个本地终端(tty_open 的实现)。
1269
+ *
1270
+ * 设计前提(与用户确认过):**开成面板里的普通会话,不做隐形会话** ——
1271
+ * 会话照常进 `sessions` 快照、面板能看见并接管、用户随时可以关。理由是
1272
+ * D06 那类「僵尸会话」正是隐形会话的产物:用户不知道机器上跑着什么。
1273
+ *
1274
+ * 与 `spawn` 帧的差别只有两处:没有 ws(clients 空表)、owner:'agent'
1275
+ * (逃过孤儿回收,见 reapOrphans)。
1276
+ */
1277
+ async openAgentSession(input) {
1278
+ if (!this.sessions.canSpawn()) {
1279
+ throw new Error(`会话数已达上限(${this.sessions.limitValue})——先在面板里关掉不用的标签,或调大「并发会话上限」`);
1280
+ }
1281
+ const cwd = typeof input.cwd === 'string' && input.cwd.trim() !== '' ? input.cwd.trim() : this.options.cwd;
1282
+ if (!existsSync(cwd))
1283
+ throw new Error(`cwd 不存在: ${cwd}`);
1284
+ const sid = randomUUID();
1285
+ const command = typeof input.command === 'string' && input.command.trim() !== '' ? input.command.trim() : null;
1286
+ // 命令型会话不做 tmux 持久化(命令短命,与 spawn 帧同规则)
1287
+ const persistName = command === null && typeof input.persistName === 'string' && input.persistName !== '' && this.options.persistence === 'tmux'
1288
+ ? sanitizePersistName(input.persistName, sid)
1289
+ : null;
1290
+ // 同 persistName 已有存活会话:直接复用(跨窗口共享同语义),不新建 PTY
1291
+ if (persistName !== null) {
1292
+ const existing = this.sessions.findByTmuxName(persistName);
1293
+ if (existing !== undefined)
1294
+ return { sid: existing.id, persist: true };
1295
+ }
1296
+ const { session, degraded } = await this.createLocalSession({
1297
+ sid,
1298
+ cols: input.cols,
1299
+ rows: input.rows,
1300
+ cwd,
1301
+ command,
1302
+ persistName,
1303
+ client: null, // agent 路径:无客户端
1304
+ local: new Map(),
1305
+ owner: 'agent',
1306
+ });
1307
+ // 面板可见性:新会话推给所有已连接的面板(客户端据此建「agent 开的」标签)
1308
+ this.broadcastSessions();
1309
+ return { sid: session.id, persist: session.tmuxName !== null && !degraded };
1310
+ }
1311
+ /** agent 关掉一个会话(tty_close 的实现):只允许关 agent 自己开的,用户标签不越权。 */
1312
+ async closeAgentSession(sid) {
1313
+ const session = this.sessions.get(sid);
1314
+ if (session === undefined || session.closed)
1315
+ throw new Error(`会话不存在或已结束: ${sid}`);
1316
+ if (session.owner !== 'agent') {
1317
+ throw new Error(`会话 ${sid} 是用户在面板里开的(owner=user):请在面板里关闭那个标签,不要由 agent 越权结束`);
1318
+ }
1319
+ this.flushPendingOutput(session);
1320
+ this.killSessionNow(session);
1321
+ this.broadcastSessions();
1322
+ return { ok: true };
1323
+ }
1324
+ /**
1325
+ * 把当前会话清单推给所有已连接面板(agent 开关会话后让面板即时反映)。
1326
+ *
1327
+ * 用自己登记的连接集合而不是 `this.wss.clients`:后者只在真实 WS 服务器
1328
+ * 接线时才有值(单测直接调 onConnection 时为空),且语义上我们要的是
1329
+ * 「已接线的面板连接」。
1330
+ */
1331
+ broadcastSessions() {
1332
+ const list = this.sessions.listForAttach();
1333
+ for (const ws of this.panels) {
1334
+ send(ws, { t: 'sessions', list, tmux: [] });
1335
+ }
1336
+ }
1337
+ /**
1338
+ * 取一次会话所在机器的指标(tty_stats 的实现)。
1339
+ *
1340
+ * 按需采样、不依赖面板是否订阅状态条:本地会话直接跑本地采样器;SSH 会话在
1341
+ * 同一连接上开一次性 exec channel 跑一帧脚本(statsExec 的常驻循环不适合
1342
+ * 一次性取数,故用 handle.statsExec 的单帧变体——没有的话返回最近留档)。
1343
+ * 失败不抛给 agent 的判断链:返回 available:false + 原因。
1344
+ */
1345
+ async sampleStats(session) {
1346
+ if (session.kind === 'local') {
1347
+ try {
1348
+ const frame = await localStatsSampler().sample();
1349
+ if (!hasStatsData(frame))
1350
+ return { available: false, reason: '本机未采到可用指标(平台不支持或字段全缺)' };
1351
+ session.lastStats = frame;
1352
+ return { available: true, frame };
1353
+ }
1354
+ catch (error) {
1355
+ return { available: false, reason: `本机采样失败: ${error instanceof Error ? error.message : String(error)}` };
1356
+ }
1357
+ }
1358
+ const statsExec = session.handle.statsExec;
1359
+ if (statsExec === undefined) {
1360
+ // 没有 exec 通道(或远端不支持):退回最近留档(面板订阅过就有)
1361
+ if (session.lastStats !== null)
1362
+ return { available: true, frame: session.lastStats };
1363
+ return { available: false, reason: '该 SSH 会话没有可用的采集通道,且没有历史留档' };
1364
+ }
1365
+ // 一次性取一帧:脚本是常驻循环,收到第一帧即 stop
1366
+ return await new Promise((resolve) => {
1367
+ let settled = false;
1368
+ let handle = null;
1369
+ const finish = (result) => {
1370
+ if (settled)
1371
+ return;
1372
+ settled = true;
1373
+ try {
1374
+ handle?.stop();
1375
+ }
1376
+ catch {
1377
+ /* 已停 */
1378
+ }
1379
+ resolve(result);
1380
+ };
1381
+ const timer = setTimeout(() => { finish({ available: false, reason: '远端采集超时(3s)' }); }, 3000);
1382
+ timer.unref?.();
1383
+ try {
1384
+ handle = statsExec(buildRemoteStatsCommand(), (line) => {
1385
+ const frame = parseStatsLine(line);
1386
+ if (frame === null)
1387
+ return;
1388
+ clearTimeout(timer);
1389
+ session.lastStats = frame;
1390
+ finish({ available: true, frame });
1391
+ }, () => {
1392
+ clearTimeout(timer);
1393
+ // 一帧未读就结束:远端可能非 POSIX(Windows 远端走 PowerShell 版)
1394
+ if (session.lastStats !== null)
1395
+ finish({ available: true, frame: session.lastStats });
1396
+ else
1397
+ finish({ available: false, reason: '远端采集通道结束且未产出数据' });
1398
+ });
1399
+ }
1400
+ catch (error) {
1401
+ clearTimeout(timer);
1402
+ finish({ available: false, reason: `远端采集启动失败: ${error instanceof Error ? error.message : String(error)}` });
1403
+ }
1404
+ });
1405
+ }
1253
1406
  /** 等待同 tmuxName 的在途创建完成;返回可重绑定的会话(null = 无在途/已失败)。 */
1254
1407
  async waitPendingTmux(tmuxName) {
1255
1408
  const inflight = this.pendingTmux.get(tmuxName);
@@ -1262,6 +1415,109 @@ export class TtyServer {
1262
1415
  return null;
1263
1416
  }
1264
1417
  }
1418
+ /**
1419
+ * 创建本地会话(0.20.0 抽出,供 WS `spawn` 帧与 agent `tty_open` 共用)。
1420
+ *
1421
+ * 与连接无关是这次抽出的全部意义:`spawn` 帧带一个 ws(用户开的标签要立刻
1422
+ * ready + 收输出),`tty_open` 没有 ws(agent 开的会话从出生起就没有客户端,
1423
+ * 靠 owner:'agent' 逃过孤儿回收)。两条路径共用同一套:
1424
+ * - tmux 持久化探测与资源准备(同 persistName 复用既有会话,名额不翻倍);
1425
+ * - cwd 校验、spawnPlan 组装、并发在途收敛(pendingTmux);
1426
+ * - 会话对象装配 + 输出下行挂载 + 退出收尾。
1427
+ *
1428
+ * 调用方负责:上限检查(canSpawn)、错误帧、ready/notice 的呈现。
1429
+ * `client` 为 null 时创建无客户端的会话(agent 路径)。
1430
+ */
1431
+ async createLocalSession(input) {
1432
+ const { sid, cols, rows, cwd, command, persistName, client, local, owner } = input;
1433
+ const subprocess = this.ctx.get('subprocess');
1434
+ if (subprocess === undefined)
1435
+ throw new Error('subprocess 服务不可用');
1436
+ const wantsPersist = persistName !== null;
1437
+ let spawnPlan = command !== null
1438
+ ? buildCommandSpawn(this.options.shell, this.options.term, this.options.colorTerm, command)
1439
+ : buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
1440
+ let tmuxName = null;
1441
+ let degraded = false;
1442
+ if (wantsPersist) {
1443
+ const probe = await probeTmux();
1444
+ if (probe.available) {
1445
+ tmuxName = persistName;
1446
+ ensureTmuxAssets({ shell: this.options.shell, colorTerm: this.options.colorTerm, shellIntegration: this.options.shellIntegration, passthrough: probe.passthrough });
1447
+ spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
1448
+ }
1449
+ else {
1450
+ degraded = true; // tmux 不在:降级普通会话,由调用方给灰字提示
1451
+ }
1452
+ }
1453
+ const create = (async () => {
1454
+ const handle = wrapLocalPty(await subprocess.spawnTerminal({
1455
+ argv: spawnPlan.argv,
1456
+ rows: clampInt(rows, 24, 2, 200),
1457
+ cols: clampInt(cols, 80, 2, 500),
1458
+ cwd,
1459
+ env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
1460
+ graceMs: 5000,
1461
+ }));
1462
+ if (tmuxName !== null) {
1463
+ handle.tmuxTeardown = () => killTmuxSession(tmuxName);
1464
+ handle.tmuxRefresh = () => refreshTmuxClient(tmuxName);
1465
+ }
1466
+ const next = {
1467
+ id: sid,
1468
+ handle,
1469
+ clients: new Map(),
1470
+ closed: false,
1471
+ paused: false,
1472
+ owner,
1473
+ cwd,
1474
+ kind: 'local',
1475
+ target: '',
1476
+ startedAt: Date.now(),
1477
+ lastOutputAt: Date.now(),
1478
+ lastInputAt: Date.now(),
1479
+ buffer: '',
1480
+ decoder: new StringDecoder('utf8'),
1481
+ screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
1482
+ orphanedAt: null,
1483
+ shellState: createShellState(),
1484
+ pendingOutput: '',
1485
+ flushTimer: null,
1486
+ tmuxName,
1487
+ statsSubs: new Set(),
1488
+ lastStats: null,
1489
+ stats: null,
1490
+ statsFailed: false,
1491
+ };
1492
+ // 绑定:有客户端才绑(agent 路径 client === null → 保持空表 = 无客户端会话)。
1493
+ // 空表但 owner:'agent',故不会被孤儿回收器当孤儿收掉。
1494
+ if (client !== null)
1495
+ next.clients.set(client.connId + ':' + sid, { ws: client.ws, sid });
1496
+ local.set(sid, next);
1497
+ this.sessions.add(next);
1498
+ // spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
1499
+ // 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
1500
+ // 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
1501
+ // 重连 attach 还被拒并谎报「会话已连接到其它窗口」。
1502
+ if (client !== null && (!client.ws.readyState || client.ws.readyState !== WebSocket.OPEN)) {
1503
+ next.clients.clear();
1504
+ next.orphanedAt = Date.now();
1505
+ }
1506
+ return next;
1507
+ })();
1508
+ if (tmuxName !== null) {
1509
+ const registered = create.catch(() => null);
1510
+ this.pendingTmux.set(tmuxName, registered);
1511
+ void registered.finally(() => {
1512
+ if (this.pendingTmux.get(tmuxName) === registered)
1513
+ this.pendingTmux.delete(tmuxName);
1514
+ });
1515
+ }
1516
+ const session = await create;
1517
+ this.attachOutput(session);
1518
+ this.watchDone(session, local);
1519
+ return { session, wantsPersist, degraded };
1520
+ }
1265
1521
  /**
1266
1522
  * 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
1267
1523
  * 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
@@ -1357,91 +1613,33 @@ export class TtyServer {
1357
1613
  send(ws, { t: 'error', sid, m: `cwd 不存在: ${cwd}` });
1358
1614
  return;
1359
1615
  }
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,
1616
+ // 会话创建走共用工厂(与 agent tty_open 同一套);用户开的标签带连接,
1617
+ // 立刻 ready + 收输出
1618
+ let created;
1619
+ try {
1620
+ created = await this.createLocalSession({
1621
+ sid,
1622
+ cols: msg.cols,
1623
+ rows: msg.rows,
1398
1624
  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);
1625
+ command,
1626
+ persistName,
1627
+ client: { ws, connId: conn.id },
1628
+ local,
1629
+ owner: 'user',
1434
1630
  });
1435
1631
  }
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) {
1632
+ catch (error) {
1633
+ send(ws, { t: 'error', sid, m: error instanceof Error ? error.message : String(error) });
1634
+ return;
1635
+ }
1636
+ const next = created.session;
1637
+ send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
1638
+ if (created.wantsPersist && created.degraded) {
1439
1639
  const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
1440
1640
  next.buffer = tailFromSafeBoundary(next.buffer + notice, BUFFER_CAP);
1441
1641
  send(ws, { t: 'data', sid, d: notice });
1442
1642
  }
1443
- this.attachOutput(next);
1444
- this.watchDone(next, local);
1445
1643
  }
1446
1644
  else if (msg.t === 'ssh') {
1447
1645
  const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
@@ -1515,6 +1713,7 @@ export class TtyServer {
1515
1713
  clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
1516
1714
  closed: false,
1517
1715
  paused: false,
1716
+ owner: 'user',
1518
1717
  cwd: '',
1519
1718
  kind: 'ssh',
1520
1719
  target,
@@ -1530,6 +1729,7 @@ export class TtyServer {
1530
1729
  flushTimer: null,
1531
1730
  tmuxName,
1532
1731
  statsSubs: new Set(),
1732
+ lastStats: null,
1533
1733
  stats: null,
1534
1734
  statsFailed: false,
1535
1735
  };
@@ -2054,6 +2254,18 @@ function remoteBasename(path) {
2054
2254
  return base === '' ? 'download' : base;
2055
2255
  }
2056
2256
  /** 人类可读文件大小(sftp_list render 用)。 */
2257
+ /** 人类可读时长(tty_stats 的「在线时长」用)。 */
2258
+ function humanDuration(seconds) {
2259
+ const s = Math.max(0, Math.floor(seconds));
2260
+ const d = Math.floor(s / 86400);
2261
+ const h = Math.floor((s % 86400) / 3600);
2262
+ const m = Math.floor((s % 3600) / 60);
2263
+ if (d > 0)
2264
+ return `${String(d)} 天 ${String(h)} 小时`;
2265
+ if (h > 0)
2266
+ return `${String(h)} 小时 ${String(m)} 分`;
2267
+ return `${String(m)} 分`;
2268
+ }
2057
2269
  function humanFileSize(bytes) {
2058
2270
  if (!Number.isFinite(bytes) || bytes <= 0)
2059
2271
  return '0 B';
@@ -2968,7 +3180,7 @@ const plugin = definePlugin({
2968
3180
  };
2969
3181
  }, 'dsh-tty: settings');
2970
3182
  });
2971
- // agent 工具集(P1):tty_list / tty_capture / tty_send。
3183
+ // agent 工具集(P1):tty_list / tty_open / tty_close / tty_stats / tty_capture / tty_send …
2972
3184
  // 信任模型:与 bash 工具同权(agent 本就能执行任意命令),不额外加确认层;
2973
3185
  // agent 对终端的操作会实时出现在浏览器面板里(同一 PTY),天然可被用户观察。
2974
3186
  // inject: ['tools'] 声明后(见上方),ctx.get('tools') 才能解析到服务。
@@ -3021,6 +3233,7 @@ const plugin = definePlugin({
3021
3233
  startedAt: { type: 'number', required: true },
3022
3234
  lastOutputAt: { type: 'number', required: true },
3023
3235
  persist: { type: 'boolean' },
3236
+ owner: { type: 'string', required: true },
3024
3237
  },
3025
3238
  },
3026
3239
  },
@@ -3029,11 +3242,12 @@ const plugin = definePlugin({
3029
3242
  render: (_args, value) => {
3030
3243
  const sessions = value?.sessions ?? [];
3031
3244
  const text = sessions.length === 0
3032
- ? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
3245
+ ? '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
3033
3246
  : '终端面板会话:' + sessions.map((s) => {
3034
3247
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
3035
3248
  const persist = s.persist === true ? ' [tmux 持久]' : '';
3036
- return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
3249
+ const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
3250
+ return `\n- sid=${s.sid} [${s.kind}]${owner}${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
3037
3251
  }).join('');
3038
3252
  return [{ type: 'text', text }];
3039
3253
  },
@@ -3042,6 +3256,126 @@ const plugin = definePlugin({
3042
3256
  return { sessions: sessions.list() };
3043
3257
  },
3044
3258
  })));
3259
+ activeDisposers.push(tools.register(defineTool({
3260
+ name: 'tty_open',
3261
+ description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条长驻命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。cwd 缺省为插件配置的工作目录。',
3262
+ parameters: {
3263
+ cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
3264
+ command: { type: 'string', description: '直接执行的命令(非交互);给出时不做 tmux 持久化。缺省 = 交互式 shell' },
3265
+ persistName: { type: 'string', description: 'tmux 持久会话名(开启「会话持久化」时有效;同名复用既有会话)。适合宿主重启后仍需存活的长任务' },
3266
+ cols: { type: 'number', description: '列数(2~500,默认 80)' },
3267
+ rows: { type: 'number', description: '行数(2~200,默认 24)' },
3268
+ },
3269
+ output: {
3270
+ schema: {
3271
+ type: 'object',
3272
+ additionalProperties: false,
3273
+ properties: {
3274
+ sid: { type: 'string', required: true },
3275
+ persist: { type: 'boolean', required: true },
3276
+ },
3277
+ },
3278
+ render: (_args, value) => {
3279
+ const v = value;
3280
+ return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。` }];
3281
+ },
3282
+ },
3283
+ async execute(args) {
3284
+ const input = args;
3285
+ return await server.openAgentSession({
3286
+ ...(typeof input.cwd === 'string' ? { cwd: input.cwd } : {}),
3287
+ ...(typeof input.command === 'string' ? { command: input.command } : {}),
3288
+ ...(typeof input.persistName === 'string' ? { persistName: input.persistName } : {}),
3289
+ cols: input.cols,
3290
+ rows: input.rows,
3291
+ });
3292
+ },
3293
+ })));
3294
+ activeDisposers.push(tools.register(defineTool({
3295
+ name: 'tty_close',
3296
+ description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。',
3297
+ parameters: {
3298
+ sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
3299
+ },
3300
+ output: {
3301
+ schema: {
3302
+ type: 'object',
3303
+ additionalProperties: false,
3304
+ properties: { ok: { type: 'boolean', required: true } },
3305
+ },
3306
+ render: (_args, value) => {
3307
+ const v = value;
3308
+ return [{ type: 'text', text: v.ok === true ? '会话已关闭' : '会话未能关闭' }];
3309
+ },
3310
+ },
3311
+ async execute(args) {
3312
+ const input = args;
3313
+ if (typeof input.sid !== 'string' || input.sid === '')
3314
+ throw new Error('sid 必须是非空字符串');
3315
+ return await server.closeAgentSession(input.sid);
3316
+ },
3317
+ })));
3318
+ activeDisposers.push(tools.register(defineTool({
3319
+ name: 'tty_stats',
3320
+ description: '读取某个终端会话所在机器的实时指标(CPU / 内存 / 磁盘 / TCP 连接数 / 网速 / 温度 / 在线时长)——本地会话取宿主机,SSH 会话取那台远程主机(另开一条非 PTY 通道,不影响终端)。部署、压测、排查「机器是不是满了」之前先看它。仅 Linux 远端字段齐全,Windows 远端部分字段可采,macOS/BSD 远端取不到。',
3321
+ parameters: {
3322
+ sid: { type: 'string', required: true, description: '会话 id(tty_list 提供)' },
3323
+ },
3324
+ output: {
3325
+ schema: {
3326
+ type: 'object',
3327
+ additionalProperties: false,
3328
+ properties: {
3329
+ sid: { type: 'string', required: true },
3330
+ available: { type: 'boolean', required: true },
3331
+ reason: { type: 'string' },
3332
+ target: { type: 'string' },
3333
+ cpuPct: { type: 'number' },
3334
+ cores: { type: 'number' },
3335
+ memPct: { type: 'number' },
3336
+ memUsed: { type: 'number' },
3337
+ memTotal: { type: 'number' },
3338
+ diskPct: { type: 'number' },
3339
+ diskUsed: { type: 'number' },
3340
+ diskTotal: { type: 'number' },
3341
+ tcpConns: { type: 'number' },
3342
+ rxRate: { type: 'number' },
3343
+ txRate: { type: 'number' },
3344
+ tempC: { type: 'number' },
3345
+ uptimeSec: { type: 'number' },
3346
+ },
3347
+ },
3348
+ render: (_args, value) => {
3349
+ const v = value;
3350
+ if (v.available !== true)
3351
+ return [{ type: 'text', text: `会话 ${v.sid ?? '?'} 取不到指标:${v.reason ?? '未知原因'}` }];
3352
+ const parts = [
3353
+ v.cpuPct !== undefined ? `CPU ${v.cpuPct.toFixed(0)}%${v.cores !== undefined ? `(${String(v.cores)} 核)` : ''}` : null,
3354
+ v.memPct !== undefined ? `内存 ${v.memPct.toFixed(0)}%${v.memUsed !== undefined && v.memTotal !== undefined ? `(${humanFileSize(v.memUsed)} / ${humanFileSize(v.memTotal)})` : ''}` : null,
3355
+ v.diskPct !== undefined ? `磁盘 ${v.diskPct.toFixed(0)}%${v.diskUsed !== undefined && v.diskTotal !== undefined ? `(${humanFileSize(v.diskUsed)} / ${humanFileSize(v.diskTotal)})` : ''}` : null,
3356
+ v.tcpConns !== undefined ? `TCP 连接 ${String(v.tcpConns)}` : null,
3357
+ v.rxRate !== undefined ? `网速 ↓${humanFileSize(v.rxRate)}/s ↑${humanFileSize(v.txRate ?? 0)}/s` : null,
3358
+ v.tempC !== undefined ? `温度 ${v.tempC.toFixed(0)}°C` : null,
3359
+ v.uptimeSec !== undefined ? `在线 ${humanDuration(v.uptimeSec)}` : null,
3360
+ ].filter((x) => x !== null);
3361
+ const head = `会话 ${v.sid ?? '?'}${v.target !== undefined && v.target !== '' ? `(${v.target})` : ''} 指标:`;
3362
+ return [{ type: 'text', text: head + (parts.length > 0 ? parts.join(' · ') : '(无可用字段)') }];
3363
+ },
3364
+ },
3365
+ async execute(args) {
3366
+ const input = args;
3367
+ if (typeof input.sid !== 'string' || input.sid === '')
3368
+ throw new Error('sid 必须是非空字符串');
3369
+ const session = sessions.get(input.sid);
3370
+ if (session === undefined || session.closed)
3371
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
3372
+ const result = await server.sampleStats(session);
3373
+ if (result.available !== true || result.frame === undefined) {
3374
+ return { sid: input.sid, available: false, reason: result.reason ?? '未知原因' };
3375
+ }
3376
+ return { sid: input.sid, available: true, ...(session.target !== '' ? { target: session.target } : {}), ...result.frame };
3377
+ },
3378
+ })));
3045
3379
  activeDisposers.push(tools.register(defineTool({
3046
3380
  name: 'tty_capture',
3047
3381
  description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。',
@@ -3096,8 +3430,10 @@ const plugin = definePlugin({
3096
3430
  throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
3097
3431
  }
3098
3432
  // 保尾截断: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) };
3433
+ // 收一刀也保尾——最近输出才是 agent 要的。
3434
+ // exitCode 用「键不存在」表达缺失(`?? undefined` 会留下一个
3435
+ // undefined 键,不是无损 JSON 值,宿主输出校验会判工具错,见 B33)。
3436
+ return { sid: input.sid, source: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
3101
3437
  }
3102
3438
  const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
3103
3439
  const rawTail = tailLines(session, lines);
@@ -3226,7 +3562,7 @@ const plugin = definePlugin({
3226
3562
  // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
3227
3563
  const state = session.shellState;
3228
3564
  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)) });
3565
+ finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
3230
3566
  }
3231
3567
  };
3232
3568
  const timer = setTimeout(() => {
@@ -3319,7 +3655,10 @@ const plugin = definePlugin({
3319
3655
  async execute() {
3320
3656
  // 显式挑字段(0.19.0):list() 还带 enabled / lastForwardError,
3321
3657
  // 整包展开会突破 schema 的 additionalProperties:false——PTC 生成的
3322
- // TS 类型会漏字段
3658
+ // TS 类型会漏字段。
3659
+ // error 必须是「**键不存在**」而不是「键存在但值为 undefined」:后者
3660
+ // 保留了一个 undefined,不是无损 JSON 值,宿主校验会判
3661
+ // 「must be a lossless JSON object」直接把工具调用变成 Error(B33 抓到)。
3323
3662
  return {
3324
3663
  tunnels: tunnelManager.list().map((t) => ({
3325
3664
  name: t.name,
@@ -3327,7 +3666,7 @@ const plugin = definePlugin({
3327
3666
  direction: t.direction,
3328
3667
  rule: t.rule,
3329
3668
  state: t.state,
3330
- error: t.error ?? undefined,
3669
+ ...(t.error === null || t.error === undefined ? {} : { error: t.error }),
3331
3670
  connections: t.connections,
3332
3671
  totalConnections: t.totalConnections,
3333
3672
  })),
@@ -3676,7 +4015,7 @@ const plugin = definePlugin({
3676
4015
  },
3677
4016
  })));
3678
4017
  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)');
4018
+ 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
4019
  };
3681
4020
  refreshToolsHook = registerAll;
3682
4021
  registerAll();
@@ -3731,10 +4070,11 @@ const plugin = definePlugin({
3731
4070
  text: () => {
3732
4071
  const list = sessions.list();
3733
4072
  if (list.length === 0)
3734
- return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
3735
- return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
4073
+ return '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。';
4074
+ return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + list.map((s) => {
3736
4075
  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()})`;
4076
+ const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
4077
+ return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
3738
4078
  }).join('\n');
3739
4079
  },
3740
4080
  });