@hyzyn/dsh-tty 0.16.2 → 0.17.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.d.ts CHANGED
@@ -31,6 +31,13 @@
31
31
  * S→C {t:'exit', sid, code, signal} PTY 退出事实(恰好一次)
32
32
  * S→C {t:'error', sid?, m} 错误
33
33
  * S→C {t:'sessions', list} 会话快照(attachable=true 表示前连接已断、可 attach)
34
+ * C→S {t:'statsOn'|'statsOff', sid} 订阅/退订该会话的服务器状态条(0.17.0,
35
+ * 按标签可见性驱动:首个 statsOn 才启动采集,
36
+ * 退订清零即停表并关远端 exec channel)
37
+ * S→C {t:'stats', sid, stats} 资源指标帧(0.17.0):cpuPct/cores/memUsed/
38
+ * memTotal/memPct/diskUsed/diskTotal/diskPct/
39
+ * uptimeSec/tcpConns/rxRate/txRate/tempC;缺失
40
+ * 即省略(best-effort),字节类为 bytes、速率为 B/s
34
41
  * 省略 sid 时按「该连接唯一会话」路由;连接上存在 0 或多个会话时省略 sid 报错。
35
42
  * 旧脚本(spawn 不带 sid)自动兼容:宿主生成 sid,响应帧多带 sid 字段。
36
43
  *
@@ -95,6 +102,8 @@ export interface Config {
95
102
  endOnPageClose?: boolean;
96
103
  /** SFTP 传输限制(0 = 不限)。 */
97
104
  sftpLimits?: Partial<SftpLimits>;
105
+ /** 服务器状态条(0.17.0):是否采集并推送会话资源指标(CPU/内存/磁盘/uptime/TCP/网速/温度)。默认开。 */
106
+ statsEnabled?: boolean;
98
107
  /** 内部状态:SSH 持久会话名(远程 tmux 托管,本机 socket 清单看不到,随 settings 留存供新窗口恢复确认)。 */
99
108
  persistSessions?: Array<{
100
109
  tmuxName: string;
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { parseKnownHosts } from './known-hosts.js';
22
22
  import { TunnelManager } from './tunnels.js';
23
23
  import { SftpManager } from './sftp.js';
24
24
  import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
25
+ import { buildRemoteStatsCommand, buildWindowsStatsCommand, hasStatsData, localStatsSampler, parseStatsLine } from './stats.js';
25
26
  /** SFTP 传输限制默认值。 */
26
27
  const DEFAULT_SFTP_LIMITS = { maxDownloadMb: 1024, maxUploadMb: 2048, maxUploadFiles: 1000 };
27
28
  const SSH_HOST_SCHEMA = z.object({
@@ -70,6 +71,7 @@ const TTY_SETTINGS_SCHEMA = z.object({
70
71
  sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
71
72
  persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
72
73
  endOnPageClose: z.boolean().default(false),
74
+ statsEnabled: z.boolean().default(true),
73
75
  sftpLimits: z.object({
74
76
  maxDownloadMb: z.natural().max(1024 * 1024).default(1024),
75
77
  maxUploadMb: z.natural().max(1024 * 1024).default(2048),
@@ -115,6 +117,8 @@ const BUFFER_CAP = 256 * 1024;
115
117
  const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
116
118
  /** 孤儿会话回收器的扫描间隔。 */
117
119
  const REAPER_INTERVAL_MS = 10_000;
120
+ /** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
121
+ const STATS_INTERVAL_MS = 1000;
118
122
  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} 拿结果。';
119
123
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
120
124
  function wrapLocalPty(handle) {
@@ -171,6 +175,8 @@ class LiveConfig {
171
175
  persistence;
172
176
  /** 页面断开且保活期结束时是否结束 tmux 持久会话(默认 false = 留存)。 */
173
177
  endOnPageClose;
178
+ /** 服务器状态条:是否采集并推送会话资源指标(默认 true)。 */
179
+ statsEnabled;
174
180
  /** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
175
181
  persistSessions;
176
182
  /** SFTP 传输限制(客户端浏览器侧执行)。 */
@@ -187,6 +193,8 @@ class LiveConfig {
187
193
  this.tunnels = init.tunnels ?? [];
188
194
  this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
189
195
  this.endOnPageClose = init.endOnPageClose === true;
196
+ // 只有显式 false 才关(缺省/旧配置一律视为开)
197
+ this.statsEnabled = init.statsEnabled !== false;
190
198
  this.sftpLimits = sanitizeSftpLimits(init.sftpLimits);
191
199
  this.persistSessions = init.persistSessions ?? [];
192
200
  }
@@ -215,6 +223,8 @@ class LiveConfig {
215
223
  this.persistence = partial.persistence;
216
224
  if (typeof partial.endOnPageClose === 'boolean')
217
225
  this.endOnPageClose = partial.endOnPageClose;
226
+ if (typeof partial.statsEnabled === 'boolean')
227
+ this.statsEnabled = partial.statsEnabled;
218
228
  if (partial.sftpLimits !== undefined)
219
229
  this.sftpLimits = sanitizeSftpLimits({ ...this.sftpLimits, ...partial.sftpLimits });
220
230
  if (Array.isArray(partial.persistSessions))
@@ -701,6 +711,11 @@ class SessionManager {
701
711
  attachable: session.clients.size === 0 && !session.closed,
702
712
  }));
703
713
  }
714
+ /** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
715
+ forEach(fn) {
716
+ for (const session of this.sessions.values())
717
+ fn(session);
718
+ }
704
719
  /** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
705
720
  findByTmuxName(tmuxName) {
706
721
  for (const session of this.sessions.values()) {
@@ -774,6 +789,8 @@ class TtyServer {
774
789
  pendingTmux = new Map();
775
790
  /** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
776
791
  wsGateOpen = true;
792
+ /** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
793
+ statsOn = true;
777
794
  constructor(ctx, sessions, options, hostKeyStore,
778
795
  /** SSH 持久会话名留存回调(apply 闭包实现,settings 落盘)。 */
779
796
  trackPersist) {
@@ -782,6 +799,7 @@ class TtyServer {
782
799
  this.options = options;
783
800
  this.hostKeyStore = hostKeyStore;
784
801
  this.trackPersist = trackPersist;
802
+ this.statsOn = options.statsEnabled;
785
803
  this.wss.on('connection', (ws) => this.onConnection(ws));
786
804
  }
787
805
  /**
@@ -795,6 +813,8 @@ class TtyServer {
795
813
  this.wsGateOpen = open;
796
814
  if (open)
797
815
  return;
816
+ // 禁用:采集器先停(远端 exec channel / 本地定时器都不该活过闸门)
817
+ this.stopAllStats();
798
818
  for (const ws of this.wss.clients) {
799
819
  try {
800
820
  ws.close(1001, 'dsh-tty disabled');
@@ -804,6 +824,162 @@ class TtyServer {
804
824
  }
805
825
  }
806
826
  }
827
+ /* --------------------------- 服务器状态条(0.17.0) --------------------------- */
828
+ /**
829
+ * 配置热生效:关闭时停掉全部采集(本地定时器 + 远端 exec channel);重新打开
830
+ * 时对**仍有订阅**的会话懒启动。订阅集合刻意不清——客户端只在标签可见性变化
831
+ * 时发 statsOn/statsOff,开关来回切不该要求它重发。
832
+ */
833
+ setStatsEnabled(enabled) {
834
+ this.statsOn = enabled;
835
+ if (!enabled) {
836
+ this.stopAllStats();
837
+ return;
838
+ }
839
+ this.sessions.forEach((session) => {
840
+ if (session.statsSubs.size > 0)
841
+ this.startStats(session);
842
+ });
843
+ }
844
+ /** 订阅/退订(tab 可见性驱动):退到 0 即停表,任何路径都不会让采集器空转。 */
845
+ setStatsSub(session, clientSid, on) {
846
+ if (on) {
847
+ session.statsSubs.add(clientSid);
848
+ this.startStats(session);
849
+ return;
850
+ }
851
+ session.statsSubs.delete(clientSid);
852
+ if (session.statsSubs.size === 0)
853
+ this.stopStats(session);
854
+ }
855
+ /** 清掉指向已解绑客户端(WS 关闭 / 标签换 sid 重绑)的订阅,防采集器永不收尾。 */
856
+ pruneStatsSubs(session) {
857
+ for (const clientSid of [...session.statsSubs]) {
858
+ if (!session.clients.has(clientSid))
859
+ this.setStatsSub(session, clientSid, false);
860
+ }
861
+ }
862
+ /**
863
+ * 懒启动采集(首个 statsOn 才起)。两条路径产出同形状的帧:
864
+ * - 本地:宿主进程就是那台机器,1s 定时器 + 进程级共享采样器(多个本地标签
865
+ * 共享一次 df/netstat);
866
+ * - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
867
+ * 速率类由远端算好,宿主只解析 + 清洗。
868
+ * 任何失败都静默停表并置 statsFailed(粘性,避免每秒重启):前端靠「无数据」
869
+ * 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
870
+ */
871
+ startStats(session) {
872
+ if (!this.statsOn || session.closed || session.stats !== null || session.statsFailed)
873
+ return;
874
+ if (session.kind === 'local') {
875
+ const sampler = localStatsSampler();
876
+ let busy = false;
877
+ let timer = null;
878
+ const collector = {
879
+ stop: () => {
880
+ if (timer !== null)
881
+ clearInterval(timer);
882
+ timer = null;
883
+ },
884
+ };
885
+ const tick = () => {
886
+ if (session.closed) {
887
+ collector.stop(); // 会话已回收:定时器自收尾,不依赖外部钩子是否齐全
888
+ return;
889
+ }
890
+ if (busy)
891
+ return; // 上一拍还没回来(df 卡住)就跳过,不堆积
892
+ busy = true;
893
+ void sampler.sample().then((frame) => {
894
+ busy = false;
895
+ if (session.stats !== collector || session.closed)
896
+ return;
897
+ if (hasStatsData(frame))
898
+ this.sendStats(session, frame);
899
+ });
900
+ };
901
+ timer = setInterval(tick, STATS_INTERVAL_MS);
902
+ timer.unref?.();
903
+ session.stats = collector;
904
+ tick(); // 首个订阅立刻出值,不让状态条空一个周期
905
+ return;
906
+ }
907
+ const statsExec = session.handle.statsExec;
908
+ if (statsExec === undefined) {
909
+ session.statsFailed = true;
910
+ return;
911
+ }
912
+ let stopped = false;
913
+ /** 本次采集是否读到过合法帧——决定「这一跳结束」算失败还是算远端自己收摊。 */
914
+ let sawFrame = false;
915
+ let handle = null;
916
+ const collector = {
917
+ stop: () => {
918
+ stopped = true;
919
+ handle?.stop();
920
+ },
921
+ };
922
+ /**
923
+ * 起一跳采集。远端平台事先不知道,所以先跑 POSIX 脚本;若 channel 在**一帧
924
+ * 数据都没读过**的情况下结束,说明对端不是 POSIX 平台(Windows 上 cmd.exe /
925
+ * PowerShell 根本解析不了 sh -c 脚本),再换 PowerShell 版(-EncodedCommand,
926
+ * 同帧形状)试一次。两跳都失败(如 macOS/BSD 远端:既无 /proc 也无 PowerShell)
927
+ * 才置粘性失败位,前端按「无数据」隐藏状态条。
928
+ */
929
+ const attempt = (command, powershell) => {
930
+ handle = statsExec(command, (line) => {
931
+ if (stopped || session.closed)
932
+ return;
933
+ const frame = parseStatsLine(line);
934
+ if (frame === null)
935
+ return;
936
+ sawFrame = true;
937
+ if (hasStatsData(frame))
938
+ this.sendStats(session, frame);
939
+ }, () => {
940
+ if (stopped)
941
+ return; // 我们自己停的,不算失败
942
+ if (!sawFrame && !powershell) {
943
+ attempt(buildWindowsStatsCommand(), true);
944
+ return;
945
+ }
946
+ // 读过帧 = 远端采集进程自己停了;一帧未读 = 彻底失败——都停表并置粘性失败位
947
+ session.statsFailed = true;
948
+ session.stats = null;
949
+ collector.stop();
950
+ });
951
+ };
952
+ // 先登记再起采集:同步失败(conn.exec 直接抛错)也走同一套收尾
953
+ session.stats = collector;
954
+ attempt(buildRemoteStatsCommand(), false);
955
+ // 双跳同步失败时 collector.stop() 已把当时在手的句柄停掉;conn.exec 直接抛错的
956
+ // 那一跳压根没建 channel,句柄是惰性的,不需要额外收尾。
957
+ }
958
+ /** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
959
+ stopStats(session) {
960
+ const collector = session.stats;
961
+ session.stats = null;
962
+ if (collector !== null)
963
+ collector.stop();
964
+ }
965
+ stopAllStats() {
966
+ this.sessions.forEach((session) => this.stopStats(session));
967
+ }
968
+ /** 帧只发给订阅了该会话的客户端(按各客户端自己的 sid 寻址,跨窗口共享也成立)。 */
969
+ sendStats(session, frame) {
970
+ for (const clientSid of session.statsSubs) {
971
+ const ws = session.clients.get(clientSid);
972
+ if (ws === undefined)
973
+ continue;
974
+ try {
975
+ send(ws, { t: 'stats', sid: clientSid, stats: frame });
976
+ }
977
+ catch {
978
+ // readyState 检查与 send 之间对端可能刚关:不 try 的话异常会落在 ws 事件
979
+ // 回调或采样 promise 里(未处理异常/未处理拒绝 → 宿主进程直接退出)
980
+ }
981
+ }
982
+ }
807
983
  /** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
808
984
  handleUpgrade(req, socket, head) {
809
985
  if (!isLoopbackUpgrade(req)) {
@@ -829,6 +1005,8 @@ class TtyServer {
829
1005
  return;
830
1006
  // 解绑本连接的客户端;其余窗口仍绑定着(跨连接共享)时会话继续在线
831
1007
  session.clients.delete(clientSid);
1008
+ // WS 关闭/转孤儿:该端的 stats 订阅一并解绑(退到 0 就停表关 channel)
1009
+ this.pruneStatsSubs(session);
832
1010
  if (session.clients.size > 0) {
833
1011
  this.flushPendingOutput(session);
834
1012
  return;
@@ -888,6 +1066,7 @@ class TtyServer {
888
1066
  rebindClient(session, sid, ws, local) {
889
1067
  session.clients.set(sid, ws);
890
1068
  session.orphanedAt = null;
1069
+ this.pruneStatsSubs(session);
891
1070
  if (session.paused) {
892
1071
  session.paused = false;
893
1072
  try {
@@ -929,6 +1108,9 @@ class TtyServer {
929
1108
  * 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
930
1109
  */
931
1110
  killSessionNow(session) {
1111
+ // 采集器先收(远端 exec channel 与本地定时器都不该活过会话)
1112
+ session.statsSubs.clear();
1113
+ this.stopStats(session);
932
1114
  this.sessions.retire(session);
933
1115
  const teardown = session.handle.tmuxTeardown;
934
1116
  if (teardown !== undefined) {
@@ -1059,6 +1241,9 @@ class TtyServer {
1059
1241
  pendingOutput: '',
1060
1242
  flushTimer: null,
1061
1243
  tmuxName,
1244
+ statsSubs: new Set(),
1245
+ stats: null,
1246
+ statsFailed: false,
1062
1247
  };
1063
1248
  local.set(sid, next);
1064
1249
  this.sessions.add(next);
@@ -1165,6 +1350,9 @@ class TtyServer {
1165
1350
  pendingOutput: '',
1166
1351
  flushTimer: null,
1167
1352
  tmuxName,
1353
+ statsSubs: new Set(),
1354
+ stats: null,
1355
+ statsFailed: false,
1168
1356
  };
1169
1357
  local.set(sid, next);
1170
1358
  this.sessions.add(next);
@@ -1302,11 +1490,27 @@ class TtyServer {
1302
1490
  send(ws, { t: 'data', sid: raw, d: session.buffer });
1303
1491
  }
1304
1492
  }
1493
+ else if (msg.t === 'statsOn' || msg.t === 'statsOff') {
1494
+ this.handleStatsFrame(ws, msg, local);
1495
+ }
1305
1496
  }
1306
1497
  catch (error) {
1307
1498
  send(ws, { t: 'error', m: error instanceof Error ? error.message : String(error) });
1308
1499
  }
1309
1500
  }
1501
+ /**
1502
+ * 服务器状态条订阅(0.17.0):按「标签可见性」驱动——只有可见标签才发
1503
+ * statsOn。未知 sid(客户端竞态)静默忽略,不回错误帧。
1504
+ */
1505
+ handleStatsFrame(ws, msg, local) {
1506
+ const resolved = this.resolveSid(ws, msg, local);
1507
+ if (resolved === undefined || 'unknown' in resolved)
1508
+ return;
1509
+ const session = local.get(resolved.sid);
1510
+ if (session === undefined || session.closed)
1511
+ return;
1512
+ this.setStatsSub(session, resolved.sid, msg.t === 'statsOn');
1513
+ }
1310
1514
  /** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
1311
1515
  watchDone(session, local) {
1312
1516
  session.handle.done.then((outcome) => {
@@ -1316,6 +1520,8 @@ class TtyServer {
1316
1520
  return;
1317
1521
  session.exitSent = true;
1318
1522
  session.closed = true;
1523
+ session.statsSubs.clear();
1524
+ this.stopStats(session);
1319
1525
  local.delete(session.id);
1320
1526
  this.sessions.remove(session.id);
1321
1527
  if (session.kind === 'ssh' && session.tmuxName !== null)
@@ -1650,6 +1856,7 @@ const plugin = definePlugin({
1650
1856
  tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
1651
1857
  persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
1652
1858
  endOnPageClose: config?.endOnPageClose === true,
1859
+ statsEnabled: config?.statsEnabled !== false,
1653
1860
  sftpLimits: sanitizeSftpLimits(config?.sftpLimits),
1654
1861
  persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
1655
1862
  });
@@ -1712,6 +1919,7 @@ const plugin = definePlugin({
1712
1919
  sftpStyle: stateRef.sftpStyle,
1713
1920
  persistence: live.persistence,
1714
1921
  endOnPageClose: live.endOnPageClose,
1922
+ statsEnabled: live.statsEnabled,
1715
1923
  sftpLimits: live.sftpLimits,
1716
1924
  toolsRegistered: stateRef.toolsRegistered,
1717
1925
  });
@@ -1729,6 +1937,7 @@ const plugin = definePlugin({
1729
1937
  tunnels: sanitizeTunnels(section.tunnels),
1730
1938
  persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
1731
1939
  endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
1940
+ statsEnabled: typeof section.statsEnabled === 'boolean' ? section.statsEnabled : undefined,
1732
1941
  sftpLimits: typeof section.sftpLimits === 'object' && section.sftpLimits !== null ? section.sftpLimits : undefined,
1733
1942
  persistSessions: sanitizePersistSessions(section.persistSessions),
1734
1943
  });
@@ -1750,12 +1959,14 @@ const plugin = definePlugin({
1750
1959
  refreshToolsHook();
1751
1960
  refreshAnnouncementHook();
1752
1961
  server.setWsGate(stateRef.enabled);
1962
+ // 状态条开关热生效:关的时候停掉全部采集(本地定时器 + 远端 exec channel)
1963
+ server.setStatsEnabled(live.statsEnabled);
1753
1964
  console.log(`[dsh-tty] config applied (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue}, sshHosts=${live.sshHosts.length}, enabled=${String(stateRef.enabled)})`);
1754
1965
  };
1755
1966
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
1756
1967
  const normalizePatch = (input) => {
1757
1968
  const patch = {};
1758
- const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'sftpLimits']);
1969
+ const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose', 'statsEnabled', 'sftpLimits']);
1759
1970
  for (const key of Object.keys(input)) {
1760
1971
  if (!known.has(key))
1761
1972
  return { error: '未知配置项: ' + key };
@@ -1802,6 +2013,11 @@ const plugin = definePlugin({
1802
2013
  return { error: 'endOnPageClose 必须是布尔值' };
1803
2014
  patch.endOnPageClose = input.endOnPageClose;
1804
2015
  }
2016
+ if (input.statsEnabled !== undefined) {
2017
+ if (typeof input.statsEnabled !== 'boolean')
2018
+ return { error: 'statsEnabled 必须是布尔值' };
2019
+ patch.statsEnabled = input.statsEnabled;
2020
+ }
1805
2021
  for (const key of ['shell', 'term', 'colorTerm']) {
1806
2022
  if (input[key] === undefined)
1807
2023
  continue;