@hyzyn/dsh-tty 0.27.0 → 0.30.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
@@ -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, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
19
19
  import { probeSsh } from './probe.js';
20
- import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
20
+ import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath, tmuxSocketName } from './shell-integration.js';
21
21
  import { KEY_VOCABULARY, resolveKeys } from './keys.js';
22
22
  import { parseSshConfigDetailed } from './ssh-config.js';
23
23
  import { parseKnownHostsDetailed } from './known-hosts.js';
@@ -252,6 +252,30 @@ const BUFFER_CAP = 256 * 1024;
252
252
  const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
253
253
  /** 孤儿会话回收器的扫描间隔。 */
254
254
  const REAPER_INTERVAL_MS = 10_000;
255
+ /** 进程级挂载序号在 `globalThis` 上的键(见 `nextMountSerial`)。 */
256
+ const MOUNT_SERIAL_KEY = '__dshTtyMountSerial';
257
+ /**
258
+ * 本进程内第几次挂载(0.30.0 可观测性)。
259
+ *
260
+ * 为什么放 `globalThis` 而不是模块态:**模块态在 HMR 重挂时会被重新求值**——序号从 1 重来,
261
+ * 而这里要回答的恰恰是「同一个 pid 里这是第几次挂载」,模块态答不了这个问题。宿主重启会清零
262
+ * (换了进程),那正是我们要的语义。
263
+ */
264
+ function nextMountSerial() {
265
+ const bag = globalThis;
266
+ const next = (bag[MOUNT_SERIAL_KEY] ?? 0) + 1;
267
+ bag[MOUNT_SERIAL_KEY] = next;
268
+ return next;
269
+ }
270
+ /**
271
+ * 孤儿会话被回收时的留痕文案(导出仅供单测)。
272
+ *
273
+ * 报数与被回收的 sid **同源**(`SessionManager.reapOrphans` 的返回值),不在调用方另数一遍;
274
+ * 断线保活的秒数一起写进去——读日志的人不必回头翻配置。
275
+ */
276
+ export function reapedOrphansMessage(sids, graceSec) {
277
+ return `[dsh-tty] 断线保活已过(${String(graceSec)}s):回收 ${String(sids.length)} 个孤儿会话 ${sids.join(', ')} —— 它们里面跑的程序一并结束;想跨断线留住现场请用 tmux 持久标签,或在设置卡片把「断线保活」调大`;
278
+ }
255
279
  /** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
256
280
  const STATS_INTERVAL_MS = 1000;
257
281
  /**
@@ -271,7 +295,23 @@ const STATS_RETRY_MAX_MS = 300_000;
271
295
  * 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
272
296
  */
273
297
  const STATS_ONESHOT_TIMEOUT_MS = 15_000;
274
- 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,以及 `running`——这个会话**有没有命令在跑**,文本里三态写作 `[空闲]` / `[运行中——现在别往里发命令]` / `[命令状态未知]`;命令状态未知**不是**没在跑),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键(控制键/方向键用具名 `keys`,别在 data 里拼转义序列),tty_run 一次调用跑完一条命令并直接拿回尾部输出+退出码(想省掉 open→等→capture→close 四步时用它),tunnel_list 列出端口转发隧道状态、tunnel_start / tunnel_stop 启停一条——操作会实时显示在用户终端里。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} 拿结果。';
298
+ /**
299
+ * 注入 systemPrompt 的常驻指引。
300
+ *
301
+ * **先写「什么时候别用它」**(决策句在最前):这段文本每一轮都在上下文里,而模型
302
+ * 真正的失败模式是「本来 bash 一句就够,却去开一个终端会话」——把判据放最前比放末尾
303
+ * 有用。各工具的用法(`running` 三态、`last:true`、具名 `keys`、回溯语义…)**只写在
304
+ * 各自的工具描述里**,这里不再复述一遍:同一件事写两处,既白占每轮上下文,又会随
305
+ * 改动漂成两份互相矛盾的真相(本文件历史上这两处就重复过)。
306
+ *
307
+ * 这里只留工具描述里**没有**的东西:面板侧能力(SSH / SFTP / 隧道 / tmux 持久化在哪配)
308
+ * 与引导用户的话术。
309
+ */
310
+ const TTY_GUIDANCE = '**先判断要不要用终端**:只有「需要跨调用保留状态(REPL / 调试器)、要跑长驻或交互式进程、或用户要看着这条命令跑」才用 tty_open / tty_run;一次跑完的有界命令直接用 bash 工具,别在 bash 里挂起等待。用完的会话记得 tty_close——agent 开的会话不随连接回收,攒着会占会话名额与面板标签。'
311
+ + '面板(Web GUI 侧边栏「终端」)是真 PTY:vim / htop 等 TUI、多标签、断线重连都能跑,新标签默认在当前会话工作目录打开。agent 开的会话**就出现在用户面板里、用户可接管**,所以别开隐形会话。'
312
+ + '标签栏「+」还能开 SSH 标签(ssh2 原生连接;连接簿、agent forwarding、主机指纹 TOFU、单跳跳板机都在设置卡片维护);面板内另有 SFTP 文件浏览(上传/下载/建目录/重命名/删除),以及由连接簿条目配置的端口转发隧道;设置卡片开「会话持久化(tmux)」后新标签由 tmux 托管,能跨宿主重启恢复,长任务建议开着它跑。'
313
+ + '用户提到「开个终端 / 在终端里跑 / SSH 到某台机器 / 传文件 / 转发端口」时,引导他打开这个面板。'
314
+ + '典型流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
275
315
  /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
276
316
  export function wrapLocalPty(handle) {
277
317
  let resizeWarned = false;
@@ -358,6 +398,30 @@ function describeExit(exited) {
358
398
  return `signal=${exited.signal}`;
359
399
  return exited.code === null ? '退出码未知' : `exitCode=${String(exited.code)}`;
360
400
  }
401
+ /**
402
+ * 「另有 N 条标签被用户设为对 agent 不可见」那句话(0.30.0)。
403
+ *
404
+ * 两处共用这一份:`tty_list` 的渲染与每轮 systemPrompt 的会话块。同一件事写两遍必然漂,
405
+ * 而漂出来的结果是「工具里说 3 条、prompt 里说 2 条」这种对不上的话术。
406
+ *
407
+ * `count <= 0` 返回空串(调用方直接拼接,不必自己判)。
408
+ */
409
+ export function hiddenSessionsNote(count) {
410
+ if (count <= 0)
411
+ return '';
412
+ return `\n另有 ${String(count)} 条标签被用户设为「对 agent 不可见」:读不到、发不了命令,也不要拿其它 sid 去试——需要它们对 AI 开放时请用户在面板里取消隐藏。`;
413
+ }
414
+ /**
415
+ * 隐藏会话的拒绝文案(0.30.0,agent 侧工具文案只此一处)。
416
+ *
417
+ * 为什么明说「被隐藏」而不是伪装成「会话不存在」:模型拿到莫名的不存在会换个 sid 重试、
418
+ * 或以为会话崩了去重开一条——**明确的拒绝才能让它停下来并转告用户**。要藏的从来不是
419
+ * 「有这条会话」,而是它的内容与在干什么(那部分由 `listForAgent` 整条不列出负责)。
420
+ */
421
+ function hiddenRefusal(sid) {
422
+ return new Error(`会话 ${sid} 被用户设为「对 agent 不可见」:它不对 agent 开放(读输出、发命令、取指标都不行)。` +
423
+ '请让用户在终端面板里对该标签取消隐藏,或由用户自己操作它;不要用其它 sid 试探——那不是同一个终端。');
424
+ }
361
425
  /**
362
426
  * 只读保留的剩余毫秒(D77);`null` = **不按时间释放**(策略为 ∞)或不是保留态。
363
427
  *
@@ -1740,14 +1804,45 @@ export class SessionManager {
1740
1804
  lastOutputAt: session.lastOutputAt,
1741
1805
  owner: session.owner,
1742
1806
  ...runFields,
1807
+ ...(session.hiddenFromAgent ? { hidden: true } : {}),
1743
1808
  ...(session.tmuxName !== null ? { persist: true } : {}),
1744
1809
  ...exitInfo,
1745
1810
  };
1746
1811
  return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
1747
1812
  }
1748
- /** agent 工具用的只读快照。 */
1749
- list() {
1750
- return [...this.sessions.values()].map((session) => this.snapshotOf(session));
1813
+ /**
1814
+ * agent 侧用的只读快照(`tty_list` 工具与每轮 systemPrompt 的会话块)。
1815
+ *
1816
+ * **用户标了「对 agent 不可见」的会话整条不在**(0.30.0):不能只抹掉输出——会话的
1817
+ * 存在、cwd、有没有命令在跑,本身就是要藏的东西(它泄漏用户在干什么)。面板侧走
1818
+ * `listForAttach()`,那条路照旧给全部会话。
1819
+ */
1820
+ listForAgent() {
1821
+ return [...this.sessions.values()]
1822
+ .filter((session) => !session.hiddenFromAgent)
1823
+ .map((session) => this.snapshotOf(session));
1824
+ }
1825
+ /** agent 侧按 sid 取会话(0.30.0):隐藏会话与「不存在」**同样拒绝**,见 hiddenRefusal。 */
1826
+ agentView(sid) {
1827
+ const session = this.sessions.get(sid);
1828
+ if (session !== undefined && session.hiddenFromAgent)
1829
+ throw hiddenRefusal(sid);
1830
+ return session;
1831
+ }
1832
+ /**
1833
+ * 被用户隐藏的会话条数(0.30.0)。
1834
+ *
1835
+ * 存在的意义是**别让模型把话说过头**:一条都不列之后,`listForAgent()` 为空会被读成
1836
+ * 「用户没开终端」,于是它可能回一句「当前没有终端面板会话」——而用户正盯着三个标签。
1837
+ * 只报条数、不报 cwd 与活动(要藏的正是后者)。这一位信息量极低(人人都有终端),
1838
+ * 换来的是模型不再因为「看不见」而误导用户。
1839
+ */
1840
+ hiddenCount() {
1841
+ let count = 0;
1842
+ for (const session of this.sessions.values())
1843
+ if (session.hiddenFromAgent)
1844
+ count += 1;
1845
+ return count;
1751
1846
  }
1752
1847
  /** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
1753
1848
  listForAttach() {
@@ -1809,6 +1904,8 @@ export class SessionManager {
1809
1904
  */
1810
1905
  async reapOrphans(graceMs) {
1811
1906
  const now = Date.now();
1907
+ /** 本轮真的被回收的会话——**返回值就是日志报数的来源**,别在调用方另数一遍。 */
1908
+ const reaped = [];
1812
1909
  for (const session of [...this.sessions.values()]) {
1813
1910
  if (session.owner === 'agent')
1814
1911
  continue;
@@ -1820,9 +1917,11 @@ export class SessionManager {
1820
1917
  if (session.orphanedAt === null)
1821
1918
  continue;
1822
1919
  if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
1920
+ reaped.push(session);
1823
1921
  void this.destroy(session); // 后台收尾:terminate 最慢可达 ~20s,不阻塞回收器
1824
1922
  }
1825
1923
  }
1924
+ return reaped;
1826
1925
  }
1827
1926
  /**
1828
1927
  * 只读保留到点退役(D77;回收器每轮调用):超过保留期的会话出表 + 释放屏。
@@ -2156,6 +2255,17 @@ export class TtyServer {
2156
2255
  this.wss.emit('connection', ws, req);
2157
2256
  });
2158
2257
  }
2258
+ /**
2259
+ * 会话生命周期留痕(0.30.0):一行一件事,落进宿主的 startup 日志
2260
+ * (`~/.dsh/logs/startup-*.log` —— `ctx.logger` 的输出实测会进那里,`console.log` 不会)。
2261
+ *
2262
+ * 加这一组的起因见 ROADMAP 的 0.30.0 那节(会话生命周期留痕):一次「面板标签自己消失」的排查里,宿主侧
2263
+ * **一条生命周期日志都没有**,于是「插件被重挂」与「孤儿被回收」这两种解释只能靠时间吻合去猜。
2264
+ * 所以挂载/卸载、面板连接/断开、会话创建/结束、孤儿回收各留一行——宁可多几句噪音。
2265
+ */
2266
+ lifecycle(message) {
2267
+ this.ctx.logger.info(`[dsh-tty] ${message}`);
2268
+ }
2159
2269
  onConnection(ws) {
2160
2270
  /** 本连接的上下文:id 参与跨连接绑定键(D07);open 供在途异步路径判「连接已死」(D06)。 */
2161
2271
  const conn = { id: randomUUID(), open: true };
@@ -2163,6 +2273,7 @@ export class TtyServer {
2163
2273
  const local = new Map();
2164
2274
  this.panels.add(ws);
2165
2275
  ws.on('close', () => { this.panels.delete(ws); });
2276
+ this.lifecycle(`面板连接建立(当前 ${String(this.panels.size)} 个连接)`);
2166
2277
  const cleanupAll = async () => {
2167
2278
  const all = [...local.entries()];
2168
2279
  local.clear();
@@ -2200,7 +2311,13 @@ export class TtyServer {
2200
2311
  });
2201
2312
  ws.on('close', () => {
2202
2313
  conn.open = false;
2314
+ const owned = local.size;
2203
2315
  void cleanupAll();
2316
+ /*
2317
+ * 断开这一行是排查「标签自己没了」的第二个锚点(第一个是挂载/卸载那一对):宽限秒数
2318
+ * 与「几个会话转孤儿」都写进去——断线超过保活期就是回收器动手,两行能对上账。
2319
+ */
2320
+ this.lifecycle(`面板连接断开(本连接 ${String(owned)} 个会话 → 转孤儿,${String(Math.round(this.options.reconnectGraceMs / 1000))}s 内没重连就回收;还剩 ${String(this.panels.size)} 个连接)`);
2204
2321
  });
2205
2322
  ws.on('error', (error) => {
2206
2323
  this.ctx.logger.warn('[dsh-tty] ws error: ' + error.message);
@@ -2265,10 +2382,31 @@ export class TtyServer {
2265
2382
  kind: session.kind,
2266
2383
  target: session.target !== '' ? session.target : undefined,
2267
2384
  ...(session.tmuxName !== null ? { persist: true } : {}),
2385
+ ...(session.hiddenFromAgent ? { hidden: true } : {}),
2268
2386
  });
2269
2387
  if (session.tmuxName !== null)
2270
2388
  void session.handle.tmuxRefresh?.();
2271
2389
  }
2390
+ /**
2391
+ * 设置「对 agent 不可见」(0.30.0):`spawn` / `ssh` 帧(含跨窗口重绑定)与运行期的
2392
+ * `hidden` 帧**共用这一处**,避免两条路各写一遍判定。
2393
+ *
2394
+ * **只对用户标签生效**(`owner === 'user'`):隐藏是用户对「我的标签」的处置;agent
2395
+ * 若能把自己开的会话藏起来,等于把它的行为从用户眼前抹掉(D06「隐形会话」的反面)。
2396
+ * 判据放在这个唯一写入口而不是各调用点——重绑定那条路(同 persistName 撞上别的
2397
+ * 窗口/agent 的会话)也一并兜住。
2398
+ *
2399
+ * 值没变时不打日志:重连 / 重绑定会带同一个值反复走这里,每次都记一行会把
2400
+ * lifecycle 日志淹掉(那条日志的用途是「查会话为什么消失」)。
2401
+ */
2402
+ setHiddenFromAgent(session, hidden) {
2403
+ if (hidden && session.owner !== 'user')
2404
+ return;
2405
+ if (session.hiddenFromAgent === hidden)
2406
+ return;
2407
+ session.hiddenFromAgent = hidden;
2408
+ this.lifecycle(`会话可见性:sid=${session.id} kind=${session.kind} hiddenFromAgent=${String(hidden)}(${hidden ? 'agent 侧看不到这条会话' : '恢复对 agent 开放'})`);
2409
+ }
2272
2410
  /**
2273
2411
  * agent 开一个本地终端(tty_open 的实现)。
2274
2412
  *
@@ -2308,6 +2446,7 @@ export class TtyServer {
2308
2446
  client: null, // agent 路径:无客户端
2309
2447
  local: new Map(),
2310
2448
  owner: 'agent',
2449
+ hidden: false, // agent 自己开的会话没有隐藏自己的理由
2311
2450
  });
2312
2451
  // D72:agent 自己开的会话从出生起「什么都没读过」(水位线落在 seq 0)——
2313
2452
  // 包括 `command` 型会话在第一次 expect 之前打印的启动输出。用户开的标签
@@ -2440,7 +2579,7 @@ export class TtyServer {
2440
2579
  * `client` 为 null 时创建无客户端的会话(agent 路径)。
2441
2580
  */
2442
2581
  async createLocalSession(input) {
2443
- const { sid, cols, rows, cwd, command, persistName, client, local, owner } = input;
2582
+ const { sid, cols, rows, cwd, command, persistName, client, local, owner, hidden } = input;
2444
2583
  const subprocess = this.ctx.get('subprocess');
2445
2584
  if (subprocess === undefined)
2446
2585
  throw new Error('subprocess 服务不可用');
@@ -2485,6 +2624,7 @@ export class TtyServer {
2485
2624
  exited: null,
2486
2625
  paused: false,
2487
2626
  owner,
2627
+ hiddenFromAgent: hidden,
2488
2628
  cwd,
2489
2629
  kind: 'local',
2490
2630
  target: '',
@@ -2520,6 +2660,7 @@ export class TtyServer {
2520
2660
  next.clients.set(client.connId + ':' + sid, { ws: client.ws, sid });
2521
2661
  local.set(sid, next);
2522
2662
  this.sessions.add(next);
2663
+ this.lifecycle(`会话创建:sid=${sid} kind=local owner=${owner}${tmuxName !== null ? ` persist=${tmuxName}` : ''}${command !== null ? ' command' : ''}`);
2523
2664
  // spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
2524
2665
  // 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
2525
2666
  // 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
@@ -2656,6 +2797,9 @@ export class TtyServer {
2656
2797
  if (existing !== null) {
2657
2798
  if (!conn.open)
2658
2799
  return; // 等待在途创建期间连接断了:不往死连接上绑
2800
+ // 跨窗口共享的同一条 tmux 会话只有**一个** hidden 位:以本次帧为准(后写胜),
2801
+ // 与 `hidden` 帧同一条规则——不给共享会话另立一套语义
2802
+ this.setHiddenFromAgent(existing, msg.hidden === true);
2659
2803
  this.rebindClient(existing, sid, ws, local, conn.id);
2660
2804
  return;
2661
2805
  }
@@ -2684,6 +2828,7 @@ export class TtyServer {
2684
2828
  client: { ws, connId: conn.id },
2685
2829
  local,
2686
2830
  owner: 'user',
2831
+ hidden: msg.hidden === true,
2687
2832
  });
2688
2833
  }
2689
2834
  catch (error) {
@@ -2730,6 +2875,7 @@ export class TtyServer {
2730
2875
  if (existing !== null && existing.kind === 'ssh' && !existing.closed && existing.exited === null) {
2731
2876
  if (!conn.open)
2732
2877
  return; // 等待在途创建期间连接断了:不往死连接上绑
2878
+ this.setHiddenFromAgent(existing, msg.hidden === true); // 同上:共享会话一个 hidden 位
2733
2879
  this.rebindClient(existing, sid, ws, local, conn.id);
2734
2880
  return;
2735
2881
  }
@@ -2778,6 +2924,7 @@ export class TtyServer {
2778
2924
  exited: null,
2779
2925
  paused: false,
2780
2926
  owner: 'user',
2927
+ hiddenFromAgent: msg.hidden === true,
2781
2928
  cwd: '',
2782
2929
  kind: 'ssh',
2783
2930
  target,
@@ -2809,6 +2956,7 @@ export class TtyServer {
2809
2956
  };
2810
2957
  local.set(sid, next);
2811
2958
  this.sessions.add(next);
2959
+ this.lifecycle(`会话创建:sid=${sid} kind=ssh owner=user target=${target}${tmuxName !== null ? ` persist=${tmuxName}` : ''}`);
2812
2960
  // spawn 在途连接断开(0.19.0):转孤儿,理由与本地分支相同;SSH 连接
2813
2961
  // (含远程 tmux 持久会话)保持存活等重连 attach,到点由回收器收尾
2814
2962
  if (!conn.open || ws.readyState !== WebSocket.OPEN) {
@@ -2880,6 +3028,22 @@ export class TtyServer {
2880
3028
  }
2881
3029
  }
2882
3030
  }
3031
+ else if (msg.t === 'hidden') {
3032
+ // 「对 agent 不可见」(0.30.0):用户在自己面板里对**本条连接绑定的**标签切换。
3033
+ // 走 `local` 而不是全局 sid——能改这个位的只有持有该标签的那个窗口
3034
+ // (与 `resize` / `refresh` 同一条绑定语义);共享的 tmux 会话只此一个位,
3035
+ // 后写胜(与 spawn 帧重绑定时的规则一致)。
3036
+ const resolved = this.resolveSid(ws, msg, local);
3037
+ if (resolved === undefined || 'unknown' in resolved)
3038
+ return;
3039
+ const session = local.get(resolved.sid);
3040
+ if (session !== undefined && !session.closed) {
3041
+ this.setHiddenFromAgent(session, msg.hidden === true);
3042
+ // 回帧**如实回报结果**(不是回报请求):agent 开的会话会被上面的唯一写入口
3043
+ // 拒绝,客户端据实际值收敛标记——不回帧或回请求值,切换失败都长得像成功。
3044
+ send(ws, { t: 'hidden', sid: resolved.sid, hidden: session.hiddenFromAgent });
3045
+ }
3046
+ }
2883
3047
  else if (msg.t === 'refresh') {
2884
3048
  // 强制 tmux 重画(0.10.1):客户端 reset 清掉残 scrollback 后请宿主
2885
3049
  // refresh-client 重画现场——不碰尺寸,规避隐藏标签下 proposeDimensions
@@ -3041,6 +3205,8 @@ export class TtyServer {
3041
3205
  // `session.closed` 守卫要放保留态过去。终局之后的字节因 clients 已清空而不再
3042
3206
  // 成帧(`onData` 照常入环形缓冲与屏:读到的是更完整的尾巴,不是更少的)。
3043
3207
  session.exited = { code: outcome.exitCode, signal: outcome.signal, at: Date.now() };
3208
+ // 终局留痕(0.30.0):进程退出 ≠ 退役(D77 起转只读保留),这一行回答「它为什么不活着了」
3209
+ this.lifecycle(`会话结束:sid=${session.id} kind=${session.kind} owner=${session.owner} ${outcome.exitCode !== null ? `code=${String(outcome.exitCode)}` : `signal=${outcome.signal ?? '未知'}`}(转只读保留:输出与屏仍可读,直到显式关闭)`);
3044
3210
  for (const victim of this.sessions.capExited(MAX_EXITED_SESSIONS)) {
3045
3211
  // 被条数上限淘汰的:连同它在连接侧 local 表里的绑定一起摘掉(否则那条连接
3046
3212
  // 还拿得到 sid、却指着一个已退役的会话)
@@ -4848,10 +5014,16 @@ const plugin = definePlugin({
4848
5014
  },
4849
5015
  },
4850
5016
  render: (_args, value) => {
4851
- const sessions = value?.sessions ?? [];
4852
- const text = sessions.length === 0
4853
- ? '当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
4854
- : '终端面板会话:' + sessions.map((s) => {
5017
+ // 局部名用 `list`:`sessions` 在这个作用域里是**宿主的 SessionManager**
5018
+ // (下面要问它 hiddenCount),叫 sessions 会把它遮住。
5019
+ const list = value?.sessions ?? [];
5020
+ // 隐藏标签(0.30.0):列表里没有它们,但**得让 agent 知道「没有」不等于
5021
+ // 「用户没开终端」**——否则它会把这句话原样转述给正盯着自己标签的用户。
5022
+ // 只报条数;文案与每轮 prompt 块**共用** hiddenSessionsNote(一处口径)。
5023
+ const hiddenNote = hiddenSessionsNote(sessions.hiddenCount());
5024
+ const text = list.length === 0
5025
+ ? '当前没有可用的终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)。'
5026
+ : '终端面板会话:' + list.map((s) => {
4855
5027
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
4856
5028
  const persist = s.persist === true ? ' [tmux 持久]' : '';
4857
5029
  const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
@@ -4874,14 +5046,18 @@ const plugin = definePlugin({
4874
5046
  const gone = s.exited === true ? ` [已退出 ${detail}·只读保留${left}——只能读,写会报错]` : '';
4875
5047
  return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${runMark}${lastExit}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
4876
5048
  }).join('');
4877
- return [{ type: 'text', text }];
5049
+ return [{ type: 'text', text: text + hiddenNote }];
4878
5050
  },
4879
5051
  },
4880
5052
  async execute() {
4881
5053
  // D72:agent 第一次「看见」这些会话时把水位线落在当下——否则第一次
4882
5054
  // tty_expect 会把用户早先的历史输出当成「还没读过的输出」回扫过来
5055
+ // 隐藏会话(0.30.0)**照旧被落水位线**:`forEach` 遍历全部会话,包括
5056
+ // `listForAgent` 列不出来的那些。这是有意的——水位线是「agent 已读到这里」,
5057
+ // 而隐藏期间产生的输出**本来就不该**在取消隐藏后被回扫给 agent;落水位线
5058
+ // 正好让那一段留在隐藏期之内。
4883
5059
  sessions.forEach((session) => { ensureReadMark(session); });
4884
- return { sessions: sessions.list() };
5060
+ return { sessions: sessions.listForAgent() };
4885
5061
  },
4886
5062
  })));
4887
5063
  activeDisposers.push(tools.register(defineTool({
@@ -5072,7 +5248,7 @@ const plugin = definePlugin({
5072
5248
  const input = args;
5073
5249
  if (typeof input.sid !== 'string' || input.sid === '')
5074
5250
  throw new Error('sid 必须是非空字符串');
5075
- const session = sessions.get(input.sid);
5251
+ const session = sessions.agentView(input.sid);
5076
5252
  if (session === undefined || session.closed)
5077
5253
  throw new Error(`会话不存在或已退出: ${input.sid}`);
5078
5254
  if (session.exited !== null) {
@@ -5131,7 +5307,7 @@ const plugin = definePlugin({
5131
5307
  const input = args;
5132
5308
  if (typeof input.sid !== 'string' || input.sid === '')
5133
5309
  throw new Error('sid 必须是非空字符串');
5134
- const session = sessions.get(input.sid);
5310
+ const session = sessions.agentView(input.sid);
5135
5311
  if (session === undefined || session.closed)
5136
5312
  throw new Error(`会话不存在或已退出: ${input.sid}`);
5137
5313
  const useRaw = input.raw === true;
@@ -5214,7 +5390,7 @@ const plugin = definePlugin({
5214
5390
  const input = args;
5215
5391
  if (typeof input.sid !== 'string' || input.sid === '')
5216
5392
  throw new Error('sid 必须是非空字符串');
5217
- const session = sessions.get(input.sid);
5393
+ const session = sessions.agentView(input.sid);
5218
5394
  if (session === undefined || session.closed)
5219
5395
  throw new Error(`会话不存在或已退出: ${input.sid}`);
5220
5396
  const screen = session.screen;
@@ -5288,7 +5464,7 @@ const plugin = definePlugin({
5288
5464
  throw new Error('sid 必须是非空字符串');
5289
5465
  if (typeof input.pattern !== 'string' || input.pattern === '')
5290
5466
  throw new Error('pattern 必须是非空字符串');
5291
- const session = sessions.get(input.sid);
5467
+ const session = sessions.agentView(input.sid);
5292
5468
  if (session === undefined || session.closed)
5293
5469
  throw new Error(`会话不存在或已退出: ${input.sid}`);
5294
5470
  let re;
@@ -5435,7 +5611,7 @@ const plugin = definePlugin({
5435
5611
  }
5436
5612
  // 按键名先解析(未知名字在这里就报错,别等写进 PTY 才发现)
5437
5613
  const keyBytes = resolveKeys(keys);
5438
- const session = sessions.get(input.sid);
5614
+ const session = sessions.agentView(input.sid);
5439
5615
  if (session === undefined || session.closed)
5440
5616
  throw new Error(`会话不存在或已退出: ${input.sid}`);
5441
5617
  if (session.exited !== null) {
@@ -5994,12 +6170,16 @@ const plugin = definePlugin({
5994
6170
  name: 'plugin:dsh-tty:terminals',
5995
6171
  order: 150,
5996
6172
  text: () => {
5997
- const list = sessions.list();
6173
+ const list = sessions.listForAgent();
5998
6174
  // D78 补:本地命令的语法随宿主 shell 变(POSIX / cmd / PowerShell),
5999
6175
  // 而「Shell 路径」是热改的配置——所以这行每轮现算,不冻在工具描述里。
6000
6176
  const shellLine = commandShellHint(live.shell);
6177
+ // 被用户隐藏的标签(0.30.0):**只报条数**,不报 sid / cwd / 活动。没有
6178
+ // 这一行时,上面列不出任何会话会被读成「用户没开终端」,模型就会回一句
6179
+ // 「当前没有终端面板会话」——而用户正盯着自己的标签(见 hiddenCount)。
6180
+ const hiddenNote = hiddenSessionsNote(sessions.hiddenCount());
6001
6181
  if (list.length === 0)
6002
- return `当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。\n${shellLine}`;
6182
+ return `当前没有可用的终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。${hiddenNote}\n${shellLine}`;
6003
6183
  // D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
6004
6184
  // 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
6005
6185
  // 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
@@ -6028,7 +6208,7 @@ const plugin = definePlugin({
6028
6208
  const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
6029
6209
  return `sid=${s.sid} (${how})`;
6030
6210
  }).join('、');
6031
- return head + tail + '\n' + shellLine;
6211
+ return head + tail + hiddenNote + '\n' + shellLine;
6032
6212
  },
6033
6213
  });
6034
6214
  sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
@@ -6059,7 +6239,15 @@ const plugin = definePlugin({
6059
6239
  // 孤儿会话回收器:超过保活期的异常断开会话定期清理(grace=0 时为 no-op,
6060
6240
  // 断开时立即结束);插件卸载时随 effect 一起停掉
6061
6241
  const reaperTimer = setInterval(() => {
6062
- void sessions.reapOrphans(live.reconnectGraceMs);
6242
+ void sessions.reapOrphans(live.reconnectGraceMs).then((reaped) => {
6243
+ /*
6244
+ * 「断线太久 → 孤儿被回收」是「标签为什么自己没了」最常见的答案,必须留痕,
6245
+ * 而且是 warn 档:它真的结束了用户的东西(与只读保留超限淘汰同一档)。
6246
+ */
6247
+ if (reaped.length === 0)
6248
+ return;
6249
+ ctx.logger.warn(reapedOrphansMessage(reaped.map((session) => session.id), Math.round(live.reconnectGraceMs / 1000)));
6250
+ });
6063
6251
  sessions.reapExited(EXITED_RETAIN_MS); // D77:保留期策略为有限值时到点退役(∞ 时不动作,条数由 capExited 兜)
6064
6252
  }, REAPER_INTERVAL_MS);
6065
6253
  reaperTimer.unref?.();
@@ -6068,15 +6256,29 @@ const plugin = definePlugin({
6068
6256
  // 回调里,写入路径的同步 try/catch 结构性拦不住;没有兜底时任何一处虚拟屏异常
6069
6257
  // 都会直接打死宿主进程(Web GUI 掉线、会话表清空、agent 全丢)。插件卸载时摘掉。
6070
6258
  ctx.effect(() => installXtermScreenCrashGuard(), 'dsh-tty: xterm crash guard');
6259
+ // 本次挂载的**进程级**序号:卸载清点与挂载留痕共用同一个号(见 nextMountSerial)
6260
+ const mountSerial = nextMountSerial();
6071
6261
  // 插件卸载时回收全部会话、隧道与 SFTP 连接
6072
6262
  ctx.effect(() => {
6073
6263
  return () => {
6264
+ /*
6265
+ * 卸载清点(0.30.0):**实例被换掉 = 这张会话表随之清空**,面板上的标签会跟着消失,
6266
+ * 非 tmux 会话的内容再也回不来。所以「第几次挂载的那个实例被卸了、卸的时候还剩几条」
6267
+ * 是那类现象的第一手证据——必须打在 `disposeAll()` **之前**(之后表就空了)。
6268
+ */
6269
+ ctx.logger.info(`[dsh-tty] 插件卸载(本进程第 ${String(mountSerial)} 次挂载的实例):清点 ${String(sessions.liveCount)} 个在线会话、${String(sessions.exitedCount)} 个只读保留 —— 一并结束(非 tmux 会话的内容不可恢复)`);
6074
6270
  void sessions.disposeAll();
6075
6271
  tunnelManager.disposeAll();
6076
6272
  sftpManager.disposeAll();
6077
6273
  };
6078
6274
  }, 'dsh-tty: session cleanup');
6079
- console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);
6275
+ /*
6276
+ * 挂载留痕(0.30.0):**序号是进程级的**(见 `nextMountSerial`)——同一个 pid 里出现
6277
+ * 「第 2 次挂载」就意味着插件被重挂过一次,而那次重挂正是「标签自己消失」的头号嫌疑。
6278
+ * `tmux socket` 也写进来:`dsh --profile X` 起的宿主里 `DSH_PROFILE` 可能是空的,于是它
6279
+ * 和别的宿主共用裸 `dsh-tty`(2026-10-10 实测)——这种事只有打出来才看得见。
6280
+ */
6281
+ ctx.logger.info(`[dsh-tty] 插件挂载(本进程第 ${String(mountSerial)} 次,pid=${String(process.pid)}):持久化=${live.persistence} 断线保活=${String(Math.round(live.reconnectGraceMs / 1000))}s 并发上限=${String(sessions.limitValue)} tmux socket=${tmuxSocketName()} shell=${live.shell} cwd=${live.cwd}`);
6080
6282
  },
6081
6283
  });
6082
6284
  export const { name, inject, apply } = plugin;