@hyzyn/dsh-tty 0.22.0-rc.2 → 0.22.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.d.ts CHANGED
@@ -68,6 +68,7 @@
68
68
  */
69
69
  import type { Context } from '@deepseek-ai/cordis';
70
70
  import z from '@deepseek-ai/schemastery';
71
+ import { PassThrough } from 'node:stream';
71
72
  import { StringDecoder } from 'node:string_decoder';
72
73
  import WebSocket from 'ws';
73
74
  import xtermHeadless from '@xterm/headless';
@@ -155,10 +156,10 @@ export declare const Config: z;
155
156
  *
156
157
  * - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
157
158
  * 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
158
- * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(8 条,
159
+ * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
159
160
  * 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
160
161
  * 宿主 / 插件重启即清空,不会跨天累积;
161
- * - 连续跑很多短命令时,淘汰节奏变成「超过 8 条按最旧淘汰」(`capExited`),
162
+ * - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
162
163
  * 正是想要的语义:近的才有人读。
163
164
  *
164
165
  * 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
@@ -180,6 +181,24 @@ export declare const EXITED_RETAIN_MS: number;
180
181
  * 淘汰一律 `logger.warn` 留痕(见 finishSession),否则这件事在事后完全不可查。
181
182
  */
182
183
  export declare const MAX_EXITED_SESSIONS = 16;
184
+ /** DSH spawnTerminal 返回 handle 的最小形状(含内部耦合的 terminal 字段)。 */
185
+ interface PtyHandle {
186
+ pid: number;
187
+ output: PassThrough;
188
+ write(data: string): Promise<unknown>;
189
+ terminate(): Promise<unknown>;
190
+ done: Promise<{
191
+ exitCode: number | null;
192
+ signal: string | null;
193
+ }>;
194
+ /** 内部耦合:DSH 的 LocalTerminalHandle 未暴露 resize/kill,直接透传 node-pty。 */
195
+ terminal?: {
196
+ resize?(cols: number, rows: number): void;
197
+ kill?(signal: string): void;
198
+ };
199
+ }
200
+ /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
201
+ export declare function wrapLocalPty(handle: PtyHandle): TermHandle;
183
202
  /**
184
203
  * 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
185
204
  *
@@ -305,6 +324,10 @@ interface TtySession {
305
324
  stats: StatsCollector | null;
306
325
  /** 采集已永久失败(远端无 /proc、exec 被拒、连接断开):不再重启,前端隐藏状态条。 */
307
326
  statsFailed: boolean;
327
+ /** 采集失败后的重挂时刻(0 = 没有待重挂):失败位不再是粘性的(D83)。 */
328
+ statsRetryAt: number;
329
+ /** 连续失败次数:退避倍数按它递增,出过帧即归零。 */
330
+ statsFailures: number;
308
331
  }
309
332
  interface ReqLike {
310
333
  method?: string;
@@ -655,10 +678,24 @@ export declare class TtyServer {
655
678
  * 共享一次 df/netstat);
656
679
  * - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
657
680
  * 速率类由远端算好,宿主只解析 + 清洗。
658
- * 任何失败都静默停表并置 statsFailed(粘性,避免每秒重启):前端靠「无数据」
659
- * 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
681
+ * 失败时静默停表并置 statsFailed:前端靠「无数据」隐藏状态条,PTY 数据路径与
682
+ * 终端体验完全不受影响。
683
+ *
684
+ * 但失败位**不再粘死整个会话**(D83):原先置位后永不复位,一次瞬态故障
685
+ * (sshd MaxSessions 拒绝并发 channel、单通道 ECONNRESET)就让状态条与 agent
686
+ * tty_stats 在会话余生里彻底没有数据,而主 PTY 通道其实是健康的。现在按指数
687
+ * 退避自动重挂(出过帧即计数归零),既保住「别每秒重启」的本意,又能自愈。
660
688
  */
661
689
  private startStats;
690
+ /**
691
+ * 采集失败后的退避重挂(D83)。
692
+ *
693
+ * 退避而不是立刻重试,是为了保住原先「粘性失败位」想解决的问题——远端平台压根
694
+ * 没有采集源(macOS/BSD:既无 /proc 也无 PowerShell)时不能每秒重启一个必然失败
695
+ * 的 channel。指数退避 + 上限把这种「稳态失败」压到几分钟一次,同时让瞬态故障
696
+ * 在恢复后自动回到有数据状态(出过帧就归零)。
697
+ */
698
+ private scheduleStatsRetry;
662
699
  /** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
663
700
  private stopStats;
664
701
  private stopAllStats;
package/lib/index.js CHANGED
@@ -17,7 +17,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
18
18
  import { sharedGrantStore, auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
19
19
  import { probeSsh } from './probe.js';
20
- import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
20
+ import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
21
21
  import { parseSshConfigDetailed } from './ssh-config.js';
22
22
  import { parseKnownHostsDetailed } from './known-hosts.js';
23
23
  import { TunnelManager } from './tunnels.js';
@@ -147,10 +147,10 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
147
147
  *
148
148
  * - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
149
149
  * 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
150
- * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(8 条,
150
+ * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
151
151
  * 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
152
152
  * 宿主 / 插件重启即清空,不会跨天累积;
153
- * - 连续跑很多短命令时,淘汰节奏变成「超过 8 条按最旧淘汰」(`capExited`),
153
+ * - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
154
154
  * 正是想要的语义:近的才有人读。
155
155
  *
156
156
  * 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
@@ -212,10 +212,37 @@ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
212
212
  const REAPER_INTERVAL_MS = 10_000;
213
213
  /** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
214
214
  const STATS_INTERVAL_MS = 1000;
215
+ /**
216
+ * 采集失败后的退避重挂:`base × 2^(失败次数-1)`,上限 `MAX`(D83)。
217
+ *
218
+ * 上限 5 分钟是给「远端压根没有采集源」那种稳态失败留的——既要重挂(瞬态故障能自愈),
219
+ * 又不能把必然失败的 channel 重开得太勤。出过帧即计数归零,恢复正常节奏。
220
+ */
221
+ const STATS_RETRY_BASE_MS = 30_000;
222
+ const STATS_RETRY_MAX_MS = 300_000;
223
+ /**
224
+ * agent `tty_stats` 一发式远端采样的超时(D84)。
225
+ *
226
+ * 原先写死 3s,而这条路径要等**第一帧**:远端 shell 启动 + 脚本首次迭代,Windows
227
+ * 远端还要算 PowerShell 冷启动 + 多次 WMI 查询——状态条那条推送路径没有这个上限,
228
+ * 于是出现过「面板有数据、agent tty_stats 每次都报超时」的错位。放宽到 15s:工具
229
+ * 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
230
+ */
231
+ const STATS_ONESHOT_TIMEOUT_MS = 15_000;
215
232
  const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(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} 拿结果。';
216
233
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
217
- function wrapLocalPty(handle) {
234
+ export function wrapLocalPty(handle) {
218
235
  let resizeWarned = false;
236
+ // D79:README 承诺「DSH 升级若改内部结构会警告一次并退化为固定尺寸」,但老写法是
237
+ // `handle.terminal?.resize?.(…)` —— `?.` 只在**抛错**时才进 catch,DSH 若把
238
+ // `handle.terminal` 改名 / 移除(而不是让 resize 抛错),这里既不 resize 也不警告,
239
+ // 排查时一点线索都没有(承诺成了空头支票)。两种坏法现在都记一条日志。
240
+ const warnResizeDegraded = (reason) => {
241
+ if (resizeWarned)
242
+ return;
243
+ resizeWarned = true;
244
+ console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + reason);
245
+ };
219
246
  return {
220
247
  kind: 'local',
221
248
  pid: handle.pid,
@@ -223,15 +250,16 @@ function wrapLocalPty(handle) {
223
250
  done: handle.done,
224
251
  write: (data) => handle.write(data),
225
252
  resize: (cols, rows) => {
253
+ const terminal = handle.terminal;
254
+ if (terminal === undefined || typeof terminal.resize !== 'function') {
255
+ warnResizeDegraded('handle.terminal.resize 不存在');
256
+ return;
257
+ }
226
258
  try {
227
- handle.terminal?.resize?.(cols, rows);
259
+ terminal.resize(cols, rows);
228
260
  }
229
261
  catch (error) {
230
- // DSH 升级若改内部结构,降级为固定尺寸而不是每帧抛错
231
- if (!resizeWarned) {
232
- resizeWarned = true;
233
- console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + String(error?.message ?? error));
234
- }
262
+ warnResizeDegraded(String(error?.message ?? error));
235
263
  }
236
264
  },
237
265
  terminate: () => handle.terminate(),
@@ -522,9 +550,15 @@ const expectCounts = new WeakMap();
522
550
  * 整数夹紧(0.19.0):ws 帧输入零信任——`Number('abc')=NaN`、`-5`、`1.5`、
523
551
  * `1e9` 都不能原样透传给 node-pty 的 ioctl 与 xterm-headless(后者曾在
524
552
  * resize 帧路径直接炸出未捕获异常)。非法值回落 fallback,范围内取整。
553
+ *
554
+ * D79 补的一档(0.22.1):`null` / `''` / 布尔 / 对象也**不是数字**,但
555
+ * `Number(null)` = 0、`Number('')` = 0 会通过 isFinite 检查 ⇒ 被夹成下限 2。而客户端
556
+ * 把 NaN 发成 JSON 时正是 `null`(FitAddon 对游离 / 隐藏容器给 NaN,见
557
+ * `client-src/fit-size.js`),于是「根本不该发」的尺寸被翻译成「合法的极小尺寸」——
558
+ * 后台标签的 PTY 就是这么变成 2×2 的。非数值一律与 NaN 同档:回落 fallback。
525
559
  */
526
560
  function clampInt(value, fallback, min, max) {
527
- const n = Number(value);
561
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
528
562
  if (!Number.isFinite(n))
529
563
  return fallback;
530
564
  return Math.min(max, Math.max(min, Math.round(n)));
@@ -1577,8 +1611,13 @@ export class TtyServer {
1577
1611
  * 共享一次 df/netstat);
1578
1612
  * - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
1579
1613
  * 速率类由远端算好,宿主只解析 + 清洗。
1580
- * 任何失败都静默停表并置 statsFailed(粘性,避免每秒重启):前端靠「无数据」
1581
- * 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
1614
+ * 失败时静默停表并置 statsFailed:前端靠「无数据」隐藏状态条,PTY 数据路径与
1615
+ * 终端体验完全不受影响。
1616
+ *
1617
+ * 但失败位**不再粘死整个会话**(D83):原先置位后永不复位,一次瞬态故障
1618
+ * (sshd MaxSessions 拒绝并发 channel、单通道 ECONNRESET)就让状态条与 agent
1619
+ * tty_stats 在会话余生里彻底没有数据,而主 PTY 通道其实是健康的。现在按指数
1620
+ * 退避自动重挂(出过帧即计数归零),既保住「别每秒重启」的本意,又能自愈。
1582
1621
  */
1583
1622
  startStats(session) {
1584
1623
  // D77:只读保留态没有进程可采(本地会取到宿主、远端 channel 早断了)——直接不起表
@@ -1619,7 +1658,9 @@ export class TtyServer {
1619
1658
  }
1620
1659
  const statsExec = session.handle.statsExec;
1621
1660
  if (statsExec === undefined) {
1661
+ // 句柄缺失(连接已断等)同样退避重挂,而不是永久放弃(D83)
1622
1662
  session.statsFailed = true;
1663
+ this.scheduleStatsRetry(session);
1623
1664
  return;
1624
1665
  }
1625
1666
  let stopped = false;
@@ -1647,6 +1688,7 @@ export class TtyServer {
1647
1688
  if (frame === null)
1648
1689
  return;
1649
1690
  sawFrame = true;
1691
+ session.statsFailures = 0; // 出过帧:退避计数归零,下次失败从头退避
1650
1692
  if (hasStatsData(frame))
1651
1693
  this.sendStats(session, frame);
1652
1694
  }, () => {
@@ -1656,10 +1698,12 @@ export class TtyServer {
1656
1698
  attempt(buildWindowsStatsCommand(), true);
1657
1699
  return;
1658
1700
  }
1659
- // 读过帧 = 远端采集进程自己停了;一帧未读 = 彻底失败——都停表并置粘性失败位
1701
+ // 读过帧 = 远端采集进程自己停了;一帧未读 = 这一跳没起来——都停表。
1702
+ // D83:置失败位让前端先隐藏状态条,但排一次退避重挂,恢复后自愈
1660
1703
  session.statsFailed = true;
1661
1704
  session.stats = null;
1662
1705
  collector.stop();
1706
+ this.scheduleStatsRetry(session);
1663
1707
  });
1664
1708
  };
1665
1709
  // 先登记再起采集:同步失败(conn.exec 直接抛错)也走同一套收尾
@@ -1668,6 +1712,29 @@ export class TtyServer {
1668
1712
  // 双跳同步失败时 collector.stop() 已把当时在手的句柄停掉;conn.exec 直接抛错的
1669
1713
  // 那一跳压根没建 channel,句柄是惰性的,不需要额外收尾。
1670
1714
  }
1715
+ /**
1716
+ * 采集失败后的退避重挂(D83)。
1717
+ *
1718
+ * 退避而不是立刻重试,是为了保住原先「粘性失败位」想解决的问题——远端平台压根
1719
+ * 没有采集源(macOS/BSD:既无 /proc 也无 PowerShell)时不能每秒重启一个必然失败
1720
+ * 的 channel。指数退避 + 上限把这种「稳态失败」压到几分钟一次,同时让瞬态故障
1721
+ * 在恢复后自动回到有数据状态(出过帧就归零)。
1722
+ */
1723
+ scheduleStatsRetry(session) {
1724
+ if (session.closed || session.exited !== null || !this.statsOn)
1725
+ return;
1726
+ session.statsFailures += 1;
1727
+ const delay = Math.min(STATS_RETRY_BASE_MS * 2 ** (session.statsFailures - 1), STATS_RETRY_MAX_MS);
1728
+ session.statsRetryAt = Date.now() + delay;
1729
+ const timer = setTimeout(() => {
1730
+ session.statsRetryAt = 0;
1731
+ if (session.closed || session.exited !== null || !this.statsOn)
1732
+ return;
1733
+ session.statsFailed = false; // 清位后 startStats 的门禁才放行
1734
+ this.startStats(session);
1735
+ }, delay);
1736
+ timer.unref?.();
1737
+ }
1671
1738
  /** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
1672
1739
  stopStats(session) {
1673
1740
  const collector = session.stats;
@@ -1959,7 +2026,7 @@ export class TtyServer {
1959
2026
  }
1960
2027
  resolve(result);
1961
2028
  };
1962
- const timer = setTimeout(() => { finish({ available: false, reason: '远端采集超时(3s)' }); }, 3000);
2029
+ const timer = setTimeout(() => { finish({ available: false, reason: `远端采集超时(${String(STATS_ONESHOT_TIMEOUT_MS / 1000)}s)` }); }, STATS_ONESHOT_TIMEOUT_MS);
1963
2030
  timer.unref?.();
1964
2031
  try {
1965
2032
  handle = statsExec(buildRemoteStatsCommand(), (line) => {
@@ -2020,6 +2087,8 @@ export class TtyServer {
2020
2087
  : buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
2021
2088
  let tmuxName = null;
2022
2089
  let degraded = false;
2090
+ /** 降级原因:探测**超时**(机器忙)还是确定没装——提示文案必须分开(D80)。 */
2091
+ let degradedInconclusive = false;
2023
2092
  if (wantsPersist) {
2024
2093
  const probe = await probeTmux();
2025
2094
  if (probe.available) {
@@ -2028,7 +2097,8 @@ export class TtyServer {
2028
2097
  spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
2029
2098
  }
2030
2099
  else {
2031
- degraded = true; // tmux 不在:降级普通会话,由调用方给灰字提示
2100
+ degraded = true; // 降级普通会话,由调用方给灰字提示;原因(超时/没装)一并带出去
2101
+ degradedInconclusive = probe.inconclusive === true;
2032
2102
  }
2033
2103
  }
2034
2104
  const create = (async () => {
@@ -2076,6 +2146,8 @@ export class TtyServer {
2076
2146
  lastStats: null,
2077
2147
  stats: null,
2078
2148
  statsFailed: false,
2149
+ statsRetryAt: 0,
2150
+ statsFailures: 0,
2079
2151
  };
2080
2152
  // 绑定:有客户端才绑(agent 路径 client === null → 保持空表 = 无客户端会话)。
2081
2153
  // 空表但 owner:'agent',故不会被孤儿回收器当孤儿收掉。
@@ -2104,7 +2176,7 @@ export class TtyServer {
2104
2176
  const session = await create;
2105
2177
  this.attachOutput(session);
2106
2178
  this.watchDone(session, local);
2107
- return { session, wantsPersist, degraded };
2179
+ return { session, wantsPersist, degraded, degradedInconclusive };
2108
2180
  }
2109
2181
  /**
2110
2182
  * 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
@@ -2255,7 +2327,12 @@ export class TtyServer {
2255
2327
  const next = created.session;
2256
2328
  send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
2257
2329
  if (created.wantsPersist && created.degraded) {
2258
- const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
2330
+ // 超时与「确定没装」的提示分开(D80):前者让用户重开一次标签即可(那时
2331
+ // 机器已经不忙了),后者才需要去装 tmux。原先一律写「未检测到 tmux」,
2332
+ // 把「机器忙」误报成「没装」,排障方向被带偏。
2333
+ const notice = created.degradedInconclusive
2334
+ ? '\x1b[2m[dsh-tty] tmux 探测超时(机器较忙),本标签以普通会话运行;重开一个标签页即可再试持久化\x1b[0m\r\n'
2335
+ : '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
2259
2336
  appendOutput(next, notice);
2260
2337
  send(ws, { t: 'data', sid, d: notice });
2261
2338
  }
@@ -2359,6 +2436,8 @@ export class TtyServer {
2359
2436
  lastStats: null,
2360
2437
  stats: null,
2361
2438
  statsFailed: false,
2439
+ statsRetryAt: 0,
2440
+ statsFailures: 0,
2362
2441
  };
2363
2442
  local.set(sid, next);
2364
2443
  this.sessions.add(next);
@@ -2414,6 +2493,9 @@ export class TtyServer {
2414
2493
  }
2415
2494
  }
2416
2495
  else if (msg.t === 'resize') {
2496
+ // D79:`msg.cols/rows` 缺失或非数值(客户端把 NaN 序列化成 `null` 就是这种)时
2497
+ // clampInt 回落 80×24,**不是**夹到下限 2——客户端的门槛在 client-src/fit-size.js,
2498
+ // 这里是防御纵深:旧版本客户端 / 第三方客户端发来的垃圾值不该把 PTY 压成 2×2。
2417
2499
  const resolved = this.resolveSid(ws, msg, local);
2418
2500
  if (resolved === undefined || 'unknown' in resolved)
2419
2501
  return;
@@ -2473,8 +2555,13 @@ export class TtyServer {
2473
2555
  // tmux 托管、本机清单看不到,从 settings 留存读取)——客户端用它确认
2474
2556
  // localStorage 里的持久标签规格是否仍可恢复(新窗口/新浏览器)
2475
2557
  const localTmux = await listTmuxSessions();
2476
- const tmuxSessions = [...new Set([...localTmux, ...this.options.persistSessions])];
2477
- send(ws, { t: 'sessions', list: this.sessions.listForAttach(), tmux: tmuxSessions });
2558
+ // 本机清单探不明白(超时)时**整个字段缺席**(D81):只给 SSH 侧的名字会被
2559
+ // 读成「本机没有持久会话」——正是「失败伪装成确定答案」要避免的
2560
+ send(ws, {
2561
+ t: 'sessions',
2562
+ list: this.sessions.listForAttach(),
2563
+ ...(localTmux !== undefined ? { tmux: [...new Set([...localTmux, ...this.options.persistSessions])] } : {}),
2564
+ });
2478
2565
  }
2479
2566
  else if (msg.t === 'attach') {
2480
2567
  const raw = msg.sid;
@@ -4141,7 +4228,7 @@ const plugin = definePlugin({
4141
4228
  })));
4142
4229
  activeDisposers.push(tools.register(defineTool({
4143
4230
  name: 'tty_open',
4144
- description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按 **shell 语法整体**执行(`cd dir && cmd`、`a; b`、`for …; do …; done` 都可以,D78:旧版本里 `exec` 包装只跑第一条);**它跑完退出后会话不会立刻消失**:会转成只读保留(留到显式关闭,最多留 8 条),退出前最后的输出与退出码都还能用 tty_capture / tty_screen 读——所以「跑一条会结束的命令、回头再取结果」不需要套一层 `sh`。cwd 缺省为插件配置的工作目录。',
4231
+ description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按 **宿主 shell 的语法整段执行**(D78:旧版本里 `exec` 包装只跑第一条)——POSIX shell 上 `cd dir && cmd`、`a; b`、`for …; do …; done` 都可以,**Windows 的 cmd / PowerShell 则按它们自己的语法**(当前执行命令的 shell 与语法见 systemPrompt 里的终端一行);**它跑完退出后会话不会立刻消失**:会转成只读保留(留到显式关闭,最多留 16 条),退出前最后的输出与退出码都还能用 tty_capture / tty_screen 读——所以「跑一条会结束的命令、回头再取结果」不需要套一层 `sh`。cwd 缺省为插件配置的工作目录。',
4145
4232
  parameters: {
4146
4233
  cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
4147
4234
  command: { type: 'string', description: '直接执行的命令(非交互):按**整段 shell 代码**执行,`cd x && cmd`、`a; b`、管道、多行脚本都可以(D78);给出时不做 tmux 持久化。省略或全空白 = 交互式 shell' },
@@ -4160,7 +4247,7 @@ const plugin = definePlugin({
4160
4247
  },
4161
4248
  render: (_args, value) => {
4162
4249
  const v = value;
4163
- return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。` }];
4250
+ return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。\n${commandShellHint(live.shell)}` }];
4164
4251
  },
4165
4252
  },
4166
4253
  async execute(args) {
@@ -4176,7 +4263,7 @@ const plugin = definePlugin({
4176
4263
  })));
4177
4264
  activeDisposers.push(tools.register(defineTool({
4178
4265
  name: 'tty_close',
4179
- description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 8 条,超出按最旧淘汰。',
4266
+ description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 16 条,超出按最旧淘汰。',
4180
4267
  parameters: {
4181
4268
  sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
4182
4269
  },
@@ -5090,16 +5177,19 @@ const plugin = definePlugin({
5090
5177
  order: 150,
5091
5178
  text: () => {
5092
5179
  const list = sessions.list();
5180
+ // D78 补:本地命令的语法随宿主 shell 变(POSIX / cmd / PowerShell),
5181
+ // 而「Shell 路径」是热改的配置——所以这行每轮现算,不冻在工具描述里。
5182
+ const shellLine = commandShellHint(live.shell);
5093
5183
  if (list.length === 0)
5094
- return '当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。';
5184
+ return `当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。\n${shellLine}`;
5095
5185
  // D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
5096
5186
  // 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
5097
5187
  // 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
5098
- const live = list.filter((s) => s.exited !== true);
5188
+ const alive = list.filter((s) => s.exited !== true);
5099
5189
  const gone = list.filter((s) => s.exited === true);
5100
- const head = live.length === 0
5190
+ const head = alive.length === 0
5101
5191
  ? '当前没有活着的终端面板会话。'
5102
- : '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + live.map((s) => {
5192
+ : '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + alive.map((s) => {
5103
5193
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
5104
5194
  const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
5105
5195
  return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
@@ -5110,7 +5200,7 @@ const plugin = definePlugin({
5110
5200
  const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
5111
5201
  return `sid=${s.sid} (${how})`;
5112
5202
  }).join('、');
5113
- return head + tail;
5203
+ return head + tail + '\n' + shellLine;
5114
5204
  },
5115
5205
  });
5116
5206
  sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });