@hyzyn/dsh-tty 0.2.0 → 0.5.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
@@ -1,11 +1,22 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
4
6
  import { PassThrough } from 'node:stream';
7
+ import { StringDecoder } from 'node:string_decoder';
5
8
  import WebSocket, { WebSocketServer } from 'ws';
9
+ // @xterm/headless 是 CJS 包:ESM 具名导入在 Node 运行时会炸(Named export not
10
+ // found),必须默认导入后取 Terminal;类型用 InstanceType 别名保持同名可用
11
+ import xtermHeadless from '@xterm/headless';
12
+ const HeadlessTerminal = xtermHeadless.Terminal;
6
13
  import { definePlugin } from '@hyzyn/dsh-kit';
7
14
  import { defineTool } from '@deepseek-ai/dsh-tools';
8
- import { spawnSsh, sshTarget } from './ssh.js';
15
+ import { spawnSsh, sshTarget, expandHome } from './ssh.js';
16
+ import { buildShellSpawn } from './shell-integration.js';
17
+ import { parseSshConfig } from './ssh-config.js';
18
+ import { parseKnownHosts } from './known-hosts.js';
19
+ import { TunnelManager } from './tunnels.js';
9
20
  const SSH_HOST_SCHEMA = z.object({
10
21
  name: z.string(),
11
22
  host: z.string(),
@@ -15,6 +26,23 @@ const SSH_HOST_SCHEMA = z.object({
15
26
  keyPath: z.string().default(''),
16
27
  passphrase: z.string().default(''),
17
28
  password: z.string().default(''),
29
+ agentForward: z.boolean().default(false),
30
+ });
31
+ const HOST_KEY_SCHEMA = z.object({
32
+ host: z.string(),
33
+ port: z.natural().max(65535).default(22),
34
+ fingerprint: z.string(),
35
+ });
36
+ const TUNNEL_SCHEMA = z.object({
37
+ name: z.string(),
38
+ bookName: z.string(),
39
+ direction: z.union([z.const('local'), z.const('remote')]).default('local'),
40
+ localPort: z.natural().max(65535).default(0),
41
+ remoteHost: z.string().default(''),
42
+ remotePort: z.natural().max(65535).default(0),
43
+ localTargetHost: z.string().default(''),
44
+ localTargetPort: z.natural().max(65535).default(0),
45
+ enabled: z.boolean().default(true),
18
46
  });
19
47
  /** 与「设置 → 插件 → 终端面板」卡片表单对齐的 schema。 */
20
48
  const TTY_SETTINGS_SCHEMA = z.object({
@@ -25,58 +53,112 @@ const TTY_SETTINGS_SCHEMA = z.object({
25
53
  term: z.string().default('xterm-256color'),
26
54
  colorTerm: z.string().default('truecolor'),
27
55
  cwd: z.string().default(''),
56
+ reconnectGraceSec: z.natural().max(3600).default(120),
28
57
  sshHosts: z.array(SSH_HOST_SCHEMA).default([]),
58
+ hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
59
+ tunnels: z.array(TUNNEL_SCHEMA).default([]),
60
+ shellIntegration: z.boolean().default(true),
29
61
  });
30
62
  /* ------------------------------------------------------------------ *
31
63
  * 常量
32
64
  * ------------------------------------------------------------------ */
33
65
  const WS_PATH = '/api/dsh-tty/ws';
34
66
  const DEFAULT_MAX_SESSIONS = 4;
67
+ /** 断线保活默认秒数(reconnectGraceSec;0 = 旧行为,断开立即结束会话)。 */
68
+ const DEFAULT_RECONNECT_GRACE_SEC = 120;
35
69
  /** 下行背压阈值(ws.bufferedAmount 字节)。 */
36
70
  const BACKPRESSURE_HIGH = 512 * 1024;
37
71
  const BACKPRESSURE_LOW = 128 * 1024;
38
72
  const SID_RE = /^[A-Za-z0-9_-]{1,64}$/;
39
73
  const BUFFER_CAP = 256 * 1024;
40
- const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页;新标签默认在当前会话工作目录打开,工作目录可随当前会话切换。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护),像本地终端一样操作远程主机。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧也有配套工具:tty_list 列出活跃终端会话(含 SSH 会话的 target),tty_capture 读取会话近期输出(可查看 dev server/NPM 日志),tty_send 向会话发送按键——操作会实时显示在用户终端里。';
41
- /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合)。 */
74
+ /** TERM/COLORTERM 白名单:防止值里的引号破坏 -c 包装层命令(shellArgv 单引号包裹)。 */
75
+ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
76
+ /** 孤儿会话回收器的扫描间隔。 */
77
+ const REAPER_INTERVAL_MS = 10_000;
78
+ const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。长驻进程(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 列出端口转发隧道状态——操作会实时显示在用户终端里。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
79
+ /** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
42
80
  function wrapLocalPty(handle) {
81
+ let resizeWarned = false;
43
82
  return {
44
83
  kind: 'local',
45
84
  pid: handle.pid,
46
85
  output: handle.output,
47
86
  done: handle.done,
48
87
  write: (data) => handle.write(data),
49
- resize: (cols, rows) => handle.terminal.resize(cols, rows),
88
+ resize: (cols, rows) => {
89
+ try {
90
+ handle.terminal?.resize?.(cols, rows);
91
+ }
92
+ catch (error) {
93
+ // DSH 升级若改内部结构,降级为固定尺寸而不是每帧抛错
94
+ if (!resizeWarned) {
95
+ resizeWarned = true;
96
+ console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + String(error?.message ?? error));
97
+ }
98
+ }
99
+ },
50
100
  terminate: () => handle.terminate(),
51
- forceKill: () => handle.terminal.kill('SIGKILL'),
101
+ forceKill: () => {
102
+ try {
103
+ handle.terminal?.kill?.('SIGKILL');
104
+ }
105
+ catch {
106
+ /* 已退出 */
107
+ }
108
+ },
52
109
  };
53
110
  }
111
+ /** TERM/COLORTERM 值白名单校验:不合法回退 fallback(防止破坏 -c 包装层)。 */
112
+ function sanitizeTermValue(value, fallback) {
113
+ const trimmed = value.trim();
114
+ return TERM_RE.test(trimmed) ? trimmed : fallback;
115
+ }
54
116
  /** 可热更新的运行时配置(settings/updated 动态应用)。 */
55
117
  class LiveConfig {
56
118
  shell;
57
119
  term;
58
120
  colorTerm;
59
121
  cwd;
122
+ /** 异常断开后会话保活毫秒数(0 = 立即结束)。 */
123
+ reconnectGraceMs;
60
124
  sshHosts;
125
+ hostKeys;
126
+ /** 是否注入 OSC 133/7 shell 集成。 */
127
+ shellIntegration;
128
+ /** 端口转发隧道规格。 */
129
+ tunnels;
61
130
  constructor(init) {
62
131
  this.shell = init.shell;
63
- this.term = init.term;
64
- this.colorTerm = init.colorTerm;
132
+ this.term = sanitizeTermValue(init.term, 'xterm-256color');
133
+ this.colorTerm = sanitizeTermValue(init.colorTerm, 'truecolor');
65
134
  this.cwd = init.cwd;
135
+ this.reconnectGraceMs = Math.max(0, Math.min(3600, init.reconnectGraceSec)) * 1000;
66
136
  this.sshHosts = init.sshHosts ?? [];
137
+ this.hostKeys = init.hostKeys ?? [];
138
+ this.shellIntegration = init.shellIntegration;
139
+ this.tunnels = init.tunnels ?? [];
67
140
  }
68
- /** 合并部分更新;空字符串/undefined 保持原值;sshHosts 传数组即整体替换。 */
141
+ /** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
69
142
  apply(partial) {
70
143
  if (typeof partial.shell === 'string' && partial.shell.trim() !== '')
71
144
  this.shell = partial.shell.trim();
72
145
  if (typeof partial.term === 'string' && partial.term.trim() !== '')
73
- this.term = partial.term.trim();
146
+ this.term = sanitizeTermValue(partial.term, this.term);
74
147
  if (typeof partial.colorTerm === 'string' && partial.colorTerm.trim() !== '')
75
- this.colorTerm = partial.colorTerm.trim();
148
+ this.colorTerm = sanitizeTermValue(partial.colorTerm, this.colorTerm);
76
149
  if (typeof partial.cwd === 'string' && partial.cwd.trim() !== '')
77
150
  this.cwd = partial.cwd.trim();
151
+ if (typeof partial.reconnectGraceSec === 'number' && Number.isInteger(partial.reconnectGraceSec) && partial.reconnectGraceSec >= 0 && partial.reconnectGraceSec <= 3600) {
152
+ this.reconnectGraceMs = partial.reconnectGraceSec * 1000;
153
+ }
78
154
  if (Array.isArray(partial.sshHosts))
79
155
  this.sshHosts = partial.sshHosts;
156
+ if (Array.isArray(partial.hostKeys))
157
+ this.hostKeys = partial.hostKeys;
158
+ if (typeof partial.shellIntegration === 'boolean')
159
+ this.shellIntegration = partial.shellIntegration;
160
+ if (Array.isArray(partial.tunnels))
161
+ this.tunnels = partial.tunnels;
80
162
  }
81
163
  findSshHost(name) {
82
164
  return this.sshHosts.find((entry) => entry.name === name);
@@ -99,18 +181,192 @@ async function forceKill(handle) {
99
181
  }
100
182
  }
101
183
  }
102
- /**
103
- * shell argv。node-pty 的 name 优先于 env.TERM,而 DSH 硬编码 name:"dumb",
104
- * 只能在 exec 真正的 shell 之前 export(M0 A2 实测结论)。
105
- */
106
- function shellArgv(shell, term, colorTerm) {
107
- return [shell, '-c', `export TERM='${term}'; export COLORTERM='${colorTerm}'; exec "${shell}"`];
108
- }
109
184
  function send(ws, msg) {
110
- if (ws.readyState !== WebSocket.OPEN)
185
+ if (ws === null || ws.readyState !== WebSocket.OPEN)
111
186
  return;
112
187
  ws.send(JSON.stringify(msg));
113
188
  }
189
+ /**
190
+ * tty_capture 的默认清洗:剥离 OSC/CSI/杂项转义序列,并把同行内 \r 覆盖
191
+ * 收敛为最后一次覆盖结果(进度条不再刷屏)。逐行近似,不追求完整 VT 语义
192
+ * (要完整画面用 tty_screen / xterm-headless 虚拟屏)。
193
+ */
194
+ function cleanAnsiTail(raw) {
195
+ const withoutOsc = raw.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
196
+ const withoutCsi = withoutOsc.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
197
+ const withoutEsc = withoutCsi.replace(/\x1b[@-Z\\-_]/g, '');
198
+ // 先把「行尾 \r\n」(zsh 行结束常为 \r\r\n)归一成 \n,再按同行覆盖处理
199
+ // 剩余孤立的 \r —— 否则回显/输出行会被误判为覆盖而整行抹掉
200
+ const normalized = withoutEsc.replace(/\r+\n/g, '\n');
201
+ return normalized.split('\n').map((line) => {
202
+ const idx = line.lastIndexOf('\r');
203
+ return idx === -1 ? line : line.slice(idx + 1);
204
+ }).join('\n');
205
+ }
206
+ /** OSC 133 命令标记帧:\x1b]133;<A|B|D>[;<exit>](BEL|ST)。 */
207
+ const OSC133_RE = /\x1b\]133;([ABDC])(?:;(\d+))?(?:\x07|\x1b\\)/g;
208
+ /** OSC 7 cwd 上报帧:\x1b]7;file://<host><path>(BEL|ST)。 */
209
+ const OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
210
+ /** 单条命令输出捕获上限(环形,超出丢头部)。 */
211
+ const COMMAND_CAP = 256 * 1024;
212
+ /** data 帧合并窗口(毫秒):窗口内的 PTY chunk 合成一帧,显著降帧/降 CPU。 */
213
+ const FLUSH_INTERVAL_MS = 12;
214
+ /** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
215
+ const FLUSH_SIZE_CHARS = 64 * 1024;
216
+ function createShellState() {
217
+ return { carry: '', inCommand: false, cmdBuffer: '', lastCommand: null };
218
+ }
219
+ /** OSC 7 body(file://host/path)→ 解码后的路径;解析失败返回 undefined。 */
220
+ function osc7Path(body) {
221
+ try {
222
+ const url = new URL(body);
223
+ if (url.protocol !== 'file:')
224
+ return undefined;
225
+ const decoded = decodeURIComponent(url.pathname);
226
+ return decoded !== '' ? decoded : undefined;
227
+ }
228
+ catch {
229
+ return undefined;
230
+ }
231
+ }
232
+ /**
233
+ * 把一块输出喂进 shell 集成解析(cwd 跟随 + 命令边界捕获)。
234
+ * 残包处理:尾部若有未闭合的 OSC 序列(lastIndexOf('\x1b]') 起无终结符),
235
+ * 扣回 carry 等下一块拼齐;扣留部分不进命令捕获,避免半截序列混入。
236
+ * 命令捕获按「标记之间的文本段」累积——B、输出、D 常在同一 chunk 到达,
237
+ * 先处理段再翻转状态,才能把 B..D 之间的输出完整收进 lastCommand。
238
+ */
239
+ function feedShellIntegration(session, text) {
240
+ const state = session.shellState;
241
+ let data = state.carry + text;
242
+ state.carry = '';
243
+ const lastOpen = data.lastIndexOf('\x1b]');
244
+ if (lastOpen !== -1) {
245
+ const tail = data.slice(lastOpen);
246
+ if (!/\x07|\x1b\\/.test(tail)) {
247
+ if (tail.length <= 512) {
248
+ state.carry = tail;
249
+ data = data.slice(0, lastOpen);
250
+ }
251
+ // 超过 512B 仍不闭合视为垃圾:放弃扣留,整块照常处理
252
+ }
253
+ }
254
+ for (const match of data.matchAll(OSC7_RE)) {
255
+ const path = osc7Path(match[1]);
256
+ if (path !== undefined)
257
+ session.cwd = path;
258
+ }
259
+ OSC133_RE.lastIndex = 0;
260
+ let cursor = 0;
261
+ for (const match of data.matchAll(OSC133_RE)) {
262
+ const segment = data.slice(cursor, match.index).replace(OSC7_RE, '');
263
+ if (state.inCommand && segment !== '') {
264
+ state.cmdBuffer = (state.cmdBuffer + segment).slice(-COMMAND_CAP);
265
+ }
266
+ const kind = match[1];
267
+ if (kind === 'B') {
268
+ state.inCommand = true;
269
+ state.cmdBuffer = '';
270
+ }
271
+ else if (kind === 'D') {
272
+ if (state.inCommand) {
273
+ const exitCode = match[2] !== undefined ? Number(match[2]) : null;
274
+ state.lastCommand = {
275
+ output: state.cmdBuffer.slice(-COMMAND_CAP),
276
+ exitCode: exitCode !== null && Number.isFinite(exitCode) ? exitCode : null,
277
+ endedAt: Date.now(),
278
+ };
279
+ state.inCommand = false;
280
+ state.cmdBuffer = '';
281
+ }
282
+ }
283
+ // A(prompt 开始)无需记录
284
+ cursor = match.index + match[0].length;
285
+ }
286
+ if (state.inCommand) {
287
+ const rest = data.slice(cursor).replace(OSC7_RE, '');
288
+ if (rest !== '')
289
+ state.cmdBuffer = (state.cmdBuffer + rest).slice(-COMMAND_CAP);
290
+ }
291
+ }
292
+ /** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
293
+ function sanitizeTunnels(input) {
294
+ if (!Array.isArray(input))
295
+ return undefined;
296
+ const out = [];
297
+ for (const item of input) {
298
+ if (typeof item !== 'object' || item === null)
299
+ continue;
300
+ const raw = item;
301
+ if (typeof raw.name !== 'string' || raw.name.trim() === '')
302
+ continue;
303
+ if (typeof raw.bookName !== 'string' || raw.bookName.trim() === '')
304
+ continue;
305
+ const num = (value) => {
306
+ const n = Number(value);
307
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : 0;
308
+ };
309
+ out.push({
310
+ name: raw.name.trim(),
311
+ bookName: raw.bookName.trim(),
312
+ direction: raw.direction === 'remote' ? 'remote' : 'local',
313
+ localPort: num(raw.localPort),
314
+ remoteHost: typeof raw.remoteHost === 'string' ? raw.remoteHost.trim() : '',
315
+ remotePort: num(raw.remotePort),
316
+ localTargetHost: typeof raw.localTargetHost === 'string' ? raw.localTargetHost.trim() : '',
317
+ localTargetPort: num(raw.localTargetPort),
318
+ enabled: raw.enabled !== false,
319
+ });
320
+ }
321
+ return out;
322
+ }
323
+ /** 严格校验 tunnels(HTTP POST 路径);bookNames 为同次提交(或现有)的连接簿名字集合。 */
324
+ function validateTunnels(input, bookNames) {
325
+ if (!Array.isArray(input))
326
+ return { error: 'tunnels 必须是数组' };
327
+ const names = new Set();
328
+ for (const item of input) {
329
+ if (typeof item !== 'object' || item === null)
330
+ return { error: 'tunnels 条目必须是对象' };
331
+ const raw = item;
332
+ if (typeof raw.name !== 'string' || raw.name.trim() === '')
333
+ return { error: 'tunnels.name 必须是非空字符串' };
334
+ const name = raw.name.trim();
335
+ if (names.has(name))
336
+ return { error: `tunnels.name 重复: ${name}` };
337
+ names.add(name);
338
+ if (typeof raw.bookName !== 'string' || raw.bookName.trim() === '')
339
+ return { error: `tunnels「${name}」bookName 必须是非空字符串` };
340
+ if (!bookNames.has(raw.bookName.trim()))
341
+ return { error: `tunnels「${name}」引用的连接簿条目不存在: ${String(raw.bookName)}` };
342
+ const direction = raw.direction === 'remote' ? 'remote' : 'local';
343
+ const intIn = (value) => {
344
+ const n = Number(value);
345
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
346
+ };
347
+ if (direction === 'local') {
348
+ if (intIn(raw.localPort) === null)
349
+ return { error: `tunnels「${name}」local 方向需要 localPort(1~65535)` };
350
+ if (typeof raw.remoteHost !== 'string' || raw.remoteHost.trim() === '')
351
+ return { error: `tunnels「${name}」local 方向需要 remoteHost` };
352
+ if (intIn(raw.remotePort) === null)
353
+ return { error: `tunnels「${name}」local 方向需要 remotePort(1~65535)` };
354
+ }
355
+ else {
356
+ if (intIn(raw.remotePort) === null)
357
+ return { error: `tunnels「${name}」remote 方向需要 remotePort(服务端监听端口 1~65535)` };
358
+ if (intIn(raw.localTargetPort) === null)
359
+ return { error: `tunnels「${name}」remote 方向需要 localTargetPort(1~65535)` };
360
+ if (raw.remoteHost !== undefined && typeof raw.remoteHost !== 'string')
361
+ return { error: `tunnels「${name}」remoteHost 必须是字符串` };
362
+ if (raw.localTargetHost !== undefined && typeof raw.localTargetHost !== 'string')
363
+ return { error: `tunnels「${name}」localTargetHost 必须是字符串` };
364
+ }
365
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean')
366
+ return { error: `tunnels「${name}」enabled 必须是布尔值` };
367
+ }
368
+ return { tunnels: sanitizeTunnels(input) };
369
+ }
114
370
  /**
115
371
  * 宽松清洗一份 sshHosts 输入(settings 存储/热更新事件路径):
116
372
  * 不合法条目直接丢弃;输入不是数组时返回 undefined(表示「未提供,保持原值」)。
@@ -139,6 +395,7 @@ function sanitizeSshHosts(input) {
139
395
  keyPath: typeof raw.keyPath === 'string' ? raw.keyPath : '',
140
396
  passphrase: typeof raw.passphrase === 'string' ? raw.passphrase : '',
141
397
  password: typeof raw.password === 'string' ? raw.password : '',
398
+ agentForward: raw.agentForward === true,
142
399
  });
143
400
  }
144
401
  return out;
@@ -171,12 +428,86 @@ function validateSshHosts(input) {
171
428
  if (raw[key] !== undefined && typeof raw[key] !== 'string')
172
429
  return { error: `sshHosts.${key} 必须是字符串` };
173
430
  }
431
+ if (raw.agentForward !== undefined && typeof raw.agentForward !== 'boolean') {
432
+ return { error: 'sshHosts.agentForward 必须是布尔值' };
433
+ }
174
434
  if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
175
435
  return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
176
436
  }
177
437
  }
178
438
  return { hosts: sanitizeSshHosts(input) };
179
439
  }
440
+ /** 宽松清洗一份 hostKeys 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
441
+ function sanitizeHostKeys(input) {
442
+ if (!Array.isArray(input))
443
+ return undefined;
444
+ const out = [];
445
+ for (const item of input) {
446
+ if (typeof item !== 'object' || item === null)
447
+ continue;
448
+ const raw = item;
449
+ if (typeof raw.host !== 'string' || raw.host.trim() === '')
450
+ continue;
451
+ if (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '' || raw.fingerprint.length > 256)
452
+ continue;
453
+ const port = Number(raw.port);
454
+ out.push({
455
+ host: raw.host.trim().toLowerCase(),
456
+ port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22,
457
+ fingerprint: raw.fingerprint.trim(),
458
+ });
459
+ }
460
+ return out;
461
+ }
462
+ /** 严格校验一份 hostKeys 输入(HTTP POST 路径);返回错误信息或清洗后的数组。 */
463
+ function validateHostKeys(input) {
464
+ if (!Array.isArray(input))
465
+ return { error: 'hostKeys 必须是数组' };
466
+ const seen = new Set();
467
+ for (const item of input) {
468
+ if (typeof item !== 'object' || item === null)
469
+ return { error: 'hostKeys 条目必须是对象' };
470
+ const raw = item;
471
+ if (typeof raw.host !== 'string' || raw.host.trim() === '')
472
+ return { error: 'hostKeys.host 必须是非空字符串' };
473
+ if (typeof raw.fingerprint !== 'string' || raw.fingerprint.trim() === '')
474
+ return { error: 'hostKeys.fingerprint 必须是非空字符串' };
475
+ const port = Number(raw.port ?? 22);
476
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
477
+ return { error: 'hostKeys.port 必须是 1~65535 的整数' };
478
+ const key = `${raw.host.trim().toLowerCase()}:${port}`;
479
+ if (seen.has(key))
480
+ return { error: `hostKeys 主机重复: ${key}` };
481
+ seen.add(key);
482
+ }
483
+ return { keys: sanitizeHostKeys(input) };
484
+ }
485
+ /**
486
+ * TOFU 主机指纹存储:get/record 面向 spawnSsh 的 hostVerifier;
487
+ * record 时经 persist 回调写入 settings(宿主重启后钉扎仍在)。
488
+ */
489
+ class HostKeyStore {
490
+ live;
491
+ persist;
492
+ constructor(live, persist) {
493
+ this.live = live;
494
+ this.persist = persist;
495
+ }
496
+ key(host, port) {
497
+ return `${host.trim().toLowerCase()}:${port}`;
498
+ }
499
+ get(host, port) {
500
+ const key = this.key(host, port);
501
+ return this.live.hostKeys.find((record) => `${record.host}:${record.port}` === key)?.fingerprint;
502
+ }
503
+ record(host, port, fingerprint) {
504
+ const key = this.key(host, port);
505
+ const next = this.live.hostKeys.filter((record) => `${record.host}:${record.port}` !== key);
506
+ next.push({ host: host.trim().toLowerCase(), port, fingerprint });
507
+ this.live.hostKeys = next;
508
+ this.persist(next);
509
+ }
510
+ }
180
511
  /** upgrade 路由的 loopback 信任围栏(与 dsh-mcp 的 HTTP 围栏同思路,socket 版)。 */
181
512
  function isLoopbackUpgrade(req) {
182
513
  const address = req.socket.remoteAddress;
@@ -237,24 +568,69 @@ class SessionManager {
237
568
  get(id) {
238
569
  return this.sessions.get(id);
239
570
  }
240
- /** agent 工具用的只读快照(SSH 会话无本地 pid,该字段省略)。 */
571
+ /** 会话的只读快照(SSH 会话无本地 pid,该字段省略)。 */
572
+ snapshotOf(session) {
573
+ const base = {
574
+ sid: session.id,
575
+ cwd: session.cwd,
576
+ kind: session.kind,
577
+ target: session.target,
578
+ startedAt: session.startedAt,
579
+ lastOutputAt: session.lastOutputAt,
580
+ };
581
+ return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
582
+ }
583
+ /** agent 工具用的只读快照。 */
241
584
  list() {
242
- return [...this.sessions.values()].map((session) => {
243
- const base = {
244
- sid: session.id,
245
- cwd: session.cwd,
246
- kind: session.kind,
247
- target: session.target,
248
- startedAt: session.startedAt,
249
- lastOutputAt: session.lastOutputAt,
250
- };
251
- return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
252
- });
585
+ return [...this.sessions.values()].map((session) => this.snapshotOf(session));
586
+ }
587
+ /** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
588
+ listForAttach() {
589
+ return [...this.sessions.values()].map((session) => ({
590
+ ...this.snapshotOf(session),
591
+ attachable: session.ws === null && !session.closed,
592
+ }));
593
+ }
594
+ /** 同步退役:移出全局表 + 释放虚拟屏(幂等,不杀进程)。 */
595
+ retire(session) {
596
+ session.closed = true;
597
+ this.sessions.delete(session.id);
598
+ try {
599
+ session.screen?.dispose();
600
+ }
601
+ catch {
602
+ /* 已释放 */
603
+ }
604
+ }
605
+ /** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。 */
606
+ async destroy(session) {
607
+ this.retire(session);
608
+ await forceKill(session.handle);
609
+ }
610
+ /** 回收超过保活期的孤儿会话(回收器定时调用;graceMs<=0 时不动作)。 */
611
+ async reapOrphans(graceMs) {
612
+ if (graceMs <= 0)
613
+ return;
614
+ const now = Date.now();
615
+ for (const session of [...this.sessions.values()]) {
616
+ if (session.orphanedAt !== null && now - session.orphanedAt >= graceMs) {
617
+ void this.destroy(session); // 后台收尾:terminate 最慢可达 ~20s,不阻塞回收器
618
+ }
619
+ }
253
620
  }
254
621
  async disposeAll() {
255
622
  const all = [...this.sessions.values()];
256
623
  this.sessions.clear();
257
- await Promise.all(all.map((session) => forceKill(session.handle)));
624
+ await Promise.all(all.map((session) => {
625
+ session.closed = true;
626
+ try {
627
+ session.screen?.dispose();
628
+ }
629
+ catch {
630
+ /* 已释放 */
631
+ }
632
+ return forceKill(session.handle);
633
+ }));
258
634
  }
259
635
  }
260
636
  /* ------------------------------------------------------------------ *
@@ -264,11 +640,13 @@ class TtyServer {
264
640
  ctx;
265
641
  sessions;
266
642
  options;
643
+ hostKeyStore;
267
644
  wss = new WebSocketServer({ noServer: true });
268
- constructor(ctx, sessions, options) {
645
+ constructor(ctx, sessions, options, hostKeyStore) {
269
646
  this.ctx = ctx;
270
647
  this.sessions = sessions;
271
648
  this.options = options;
649
+ this.hostKeyStore = hostKeyStore;
272
650
  this.wss.on('connection', (ws) => this.onConnection(ws));
273
651
  }
274
652
  /** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
@@ -288,9 +666,18 @@ class TtyServer {
288
666
  const all = [...local.values()];
289
667
  local.clear();
290
668
  await Promise.all(all.map(async (session) => {
291
- session.closed = true;
292
- this.sessions.remove(session.id);
293
- await forceKill(session.handle);
669
+ if (session.closed)
670
+ return;
671
+ if (this.options.reconnectGraceMs > 0) {
672
+ // 客户端正常关面板会先逐个 kill(会话已移出 local),走到这里的都是
673
+ // 「异常断开仍有存活会话」:转孤儿保活,等待新连接 attach,到点由回收器清理
674
+ this.flushPendingOutput(session); // 没了收件人,待发帧直接丢弃(回放走环形缓冲)
675
+ session.ws = null;
676
+ session.orphanedAt = Date.now();
677
+ return;
678
+ }
679
+ this.flushPendingOutput(session);
680
+ this.killSessionNow(session);
294
681
  }));
295
682
  };
296
683
  ws.on('message', (raw) => {
@@ -333,6 +720,31 @@ class TtyServer {
333
720
  send(ws, { t: 'error', m: local.size === 0 ? '没有可用会话(先发 spawn)' : '存在多个会话,请指定 sid' });
334
721
  return undefined;
335
722
  }
723
+ /**
724
+ * 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
725
+ * 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
726
+ * 可拖 ~20s)由 terminate 在后台继续收尾,不阻塞 kill 帧处理。
727
+ */
728
+ killSessionNow(session) {
729
+ this.sessions.retire(session);
730
+ try {
731
+ session.handle.forceKill?.();
732
+ }
733
+ catch {
734
+ /* 已退出 */
735
+ }
736
+ void forceKill(session.handle);
737
+ }
738
+ /** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。 */
739
+ createScreen(cols, rows) {
740
+ try {
741
+ // buffer 命名空间在 xterm 5.x 是提案 API,必须开 allowProposedApi
742
+ return new HeadlessTerminal({ cols, rows, scrollback: 0, allowProposedApi: true });
743
+ }
744
+ catch {
745
+ return null;
746
+ }
747
+ }
336
748
  async handleMessage(ws, msg, local, cleanupAll) {
337
749
  try {
338
750
  if (msg.t === 'spawn') {
@@ -360,12 +772,13 @@ class TtyServer {
360
772
  send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
361
773
  return;
362
774
  }
775
+ const spawnPlan = buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
363
776
  const handle = wrapLocalPty(await subprocess.spawnTerminal({
364
- argv: shellArgv(this.options.shell, this.options.term, this.options.colorTerm),
777
+ argv: spawnPlan.argv,
365
778
  rows: Number(msg.rows) || 24,
366
779
  cols: Number(msg.cols) || 80,
367
780
  cwd,
368
- env: { TERM: this.options.term, COLORTERM: this.options.colorTerm },
781
+ env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
369
782
  graceMs: 5000,
370
783
  }));
371
784
  const next = {
@@ -380,12 +793,18 @@ class TtyServer {
380
793
  startedAt: Date.now(),
381
794
  lastOutputAt: Date.now(),
382
795
  buffer: '',
796
+ decoder: new StringDecoder('utf8'),
797
+ screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
798
+ orphanedAt: null,
799
+ shellState: createShellState(),
800
+ pendingOutput: '',
801
+ flushTimer: null,
383
802
  };
384
803
  local.set(sid, next);
385
804
  this.sessions.add(next);
386
805
  send(ws, { t: 'ready', sid, pid: handle.pid, kind: 'local' });
387
806
  this.attachOutput(next);
388
- this.watchDone(next, local, ws);
807
+ this.watchDone(next, local);
389
808
  }
390
809
  else if (msg.t === 'ssh') {
391
810
  const sid = typeof msg.sid === 'string' && msg.sid !== '' ? msg.sid : randomUUID();
@@ -415,6 +834,7 @@ class TtyServer {
415
834
  keyPath: typeof msg.keyPath === 'string' && msg.keyPath !== '' ? msg.keyPath : profile?.keyPath,
416
835
  passphrase: typeof msg.passphrase === 'string' && msg.passphrase !== '' ? msg.passphrase : profile?.passphrase,
417
836
  password: typeof msg.password === 'string' && msg.password !== '' ? msg.password : profile?.password,
837
+ agentForward: typeof msg.agentForward === 'boolean' ? msg.agentForward : profile?.agentForward ?? false,
418
838
  };
419
839
  if (spec.host === '' || spec.username === '') {
420
840
  send(ws, { t: 'error', sid, m: 'SSH 会话需要 host 与 username(或用 name 引用连接簿)' });
@@ -429,6 +849,7 @@ class TtyServer {
429
849
  cols: Number(msg.cols) || 80,
430
850
  rows: Number(msg.rows) || 24,
431
851
  logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
852
+ hostKeyStore: this.hostKeyStore,
432
853
  });
433
854
  }
434
855
  catch (error) {
@@ -447,12 +868,18 @@ class TtyServer {
447
868
  startedAt: Date.now(),
448
869
  lastOutputAt: Date.now(),
449
870
  buffer: '',
871
+ decoder: new StringDecoder('utf8'),
872
+ screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
873
+ orphanedAt: null,
874
+ shellState: createShellState(),
875
+ pendingOutput: '',
876
+ flushTimer: null,
450
877
  };
451
878
  local.set(sid, next);
452
879
  this.sessions.add(next);
453
880
  send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target });
454
881
  this.attachOutput(next);
455
- this.watchDone(next, local, ws);
882
+ this.watchDone(next, local);
456
883
  }
457
884
  else if (msg.t === 'input') {
458
885
  const resolved = this.resolveSid(ws, msg, local);
@@ -468,20 +895,72 @@ class TtyServer {
468
895
  return;
469
896
  const session = local.get(resolved.sid);
470
897
  if (session !== undefined) {
471
- session.handle.resize(Number(msg.cols) || 80, Number(msg.rows) || 24);
898
+ const cols = Number(msg.cols) || 80;
899
+ const rows = Number(msg.rows) || 24;
900
+ session.handle.resize(cols, rows);
901
+ try {
902
+ session.screen?.resize(cols, rows);
903
+ }
904
+ catch {
905
+ /* 非法尺寸或已释放 */
906
+ }
472
907
  }
473
908
  }
474
909
  else if (msg.t === 'kill') {
475
910
  const resolved = this.resolveSid(ws, msg, local);
476
- if (resolved === undefined || 'unknown' in resolved)
911
+ if (resolved === undefined)
912
+ return;
913
+ if ('unknown' in resolved) {
914
+ // 本连接没有该 sid:若是孤儿会话(前连接已断)也允许 kill,
915
+ // 避免「关闭面板杀不掉孤儿」泄漏到保活期结束
916
+ const orphan = this.sessions.get(String(msg.sid ?? ''));
917
+ if (orphan !== undefined && !orphan.closed) {
918
+ this.flushPendingOutput(orphan);
919
+ this.killSessionNow(orphan);
920
+ }
477
921
  return;
922
+ }
478
923
  const session = local.get(resolved.sid);
479
924
  if (session === undefined)
480
925
  return;
481
- session.closed = true;
482
926
  local.delete(resolved.sid);
483
- this.sessions.remove(resolved.sid);
484
- await forceKill(session.handle);
927
+ this.killSessionNow(session);
928
+ }
929
+ else if (msg.t === 'sessions') {
930
+ send(ws, { t: 'sessions', list: this.sessions.listForAttach() });
931
+ }
932
+ else if (msg.t === 'attach') {
933
+ const raw = msg.sid;
934
+ if (typeof raw !== 'string' || raw === '' || !SID_RE.test(raw)) {
935
+ send(ws, { t: 'error', m: 'attach 需要合法 sid' });
936
+ return;
937
+ }
938
+ const session = this.sessions.get(raw);
939
+ if (session === undefined || session.closed) {
940
+ send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
941
+ return;
942
+ }
943
+ if (session.ws !== null) {
944
+ send(ws, { t: 'error', sid: raw, m: '会话已连接到其它窗口' });
945
+ return;
946
+ }
947
+ // 重新绑定到本连接:解孤儿态,恢复被背压暂停的输出流
948
+ session.ws = ws;
949
+ session.orphanedAt = null;
950
+ local.set(session.id, session);
951
+ if (session.paused) {
952
+ session.paused = false;
953
+ try {
954
+ session.handle.output.resume();
955
+ }
956
+ catch {
957
+ /* 已退出 */
958
+ }
959
+ }
960
+ send(ws, { t: 'ready', sid: session.id, pid: session.handle.pid, kind: session.kind, target: session.target !== '' ? session.target : undefined, reattached: true });
961
+ // 断线期间的输出经 256KB 环形缓冲回放(缓冲为空则跳过)
962
+ if (session.buffer !== '')
963
+ send(ws, { t: 'data', sid: session.id, d: session.buffer });
485
964
  }
486
965
  }
487
966
  catch (error) {
@@ -489,30 +968,37 @@ class TtyServer {
489
968
  }
490
969
  }
491
970
  /** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
492
- watchDone(session, local, ws) {
971
+ watchDone(session, local) {
493
972
  session.handle.done.then((outcome) => {
494
- // kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit 帧恰好一次
973
+ // kill 主动关闭时会话可能已被移出 local,用 exitSent 保证 exit 帧恰好一次;
974
+ // 发送走 session.ws 动态取值——attach 换连接后 exit 也能跟着新连接走
495
975
  if (session.exitSent === true)
496
976
  return;
497
977
  session.exitSent = true;
498
978
  session.closed = true;
499
979
  local.delete(session.id);
500
980
  this.sessions.remove(session.id);
501
- send(ws, { t: 'exit', sid: session.id, code: outcome.exitCode, signal: outcome.signal });
981
+ try {
982
+ session.screen?.dispose();
983
+ }
984
+ catch {
985
+ /* 已释放 */
986
+ }
987
+ this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
988
+ send(session.ws, { t: 'exit', sid: session.id, code: outcome.exitCode, signal: outcome.signal });
502
989
  }).catch(() => { });
503
990
  }
504
991
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
505
992
  attachOutput(session) {
506
993
  const output = session.handle.output;
507
- const onData = (chunk) => {
508
- if (session.closed)
509
- return;
510
- const text = chunk.toString('utf8');
511
- session.lastOutputAt = Date.now();
512
- session.buffer = (session.buffer + text).slice(-BUFFER_CAP);
994
+ const flush = () => {
995
+ session.flushTimer = null;
513
996
  const ws = session.ws;
514
- const sid = session.id;
515
- ws.send(JSON.stringify({ t: 'data', sid, d: text }), () => {
997
+ const pending = session.pendingOutput;
998
+ if (session.closed || ws === null || pending === '')
999
+ return;
1000
+ session.pendingOutput = '';
1001
+ ws.send(JSON.stringify({ t: 'data', sid: session.id, d: pending }), () => {
516
1002
  if (session.paused && ws.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
517
1003
  output.resume();
518
1004
  }
@@ -522,8 +1008,53 @@ class TtyServer {
522
1008
  output.pause();
523
1009
  }
524
1010
  };
1011
+ const onData = (chunk) => {
1012
+ if (session.closed)
1013
+ return;
1014
+ // StringDecoder 兜跨 chunk 多字节序列,再喂 shell 集成解析与虚拟屏
1015
+ const text = session.decoder.write(chunk);
1016
+ session.lastOutputAt = Date.now();
1017
+ session.buffer = (session.buffer + text).slice(-BUFFER_CAP);
1018
+ feedShellIntegration(session, text);
1019
+ try {
1020
+ session.screen?.write(text);
1021
+ }
1022
+ catch {
1023
+ /* 虚拟屏异常不阻断输出链路 */
1024
+ }
1025
+ const ws = session.ws;
1026
+ if (ws === null)
1027
+ return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
1028
+ // data 帧合并:窗口内攒批,超阈值立即冲刷;exit/kill 前会强制 flush 保序
1029
+ session.pendingOutput += text;
1030
+ if (session.pendingOutput.length >= FLUSH_SIZE_CHARS) {
1031
+ if (session.flushTimer !== null) {
1032
+ clearTimeout(session.flushTimer);
1033
+ session.flushTimer = null;
1034
+ }
1035
+ flush();
1036
+ }
1037
+ else if (session.flushTimer === null) {
1038
+ const timer = setTimeout(flush, FLUSH_INTERVAL_MS);
1039
+ timer.unref?.();
1040
+ session.flushTimer = timer;
1041
+ }
1042
+ };
525
1043
  output.on('data', onData);
526
1044
  }
1045
+ /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。 */
1046
+ flushPendingOutput(session) {
1047
+ if (session.flushTimer !== null) {
1048
+ clearTimeout(session.flushTimer);
1049
+ session.flushTimer = null;
1050
+ }
1051
+ const ws = session.ws;
1052
+ const pending = session.pendingOutput;
1053
+ session.pendingOutput = '';
1054
+ if (session.closed || ws === null || pending === '')
1055
+ return;
1056
+ send(ws, { t: 'data', sid: session.id, d: pending });
1057
+ }
527
1058
  close() {
528
1059
  for (const client of this.wss.clients) {
529
1060
  try {
@@ -538,6 +1069,35 @@ class TtyServer {
538
1069
  /* ------------------------------------------------------------------ *
539
1070
  * 插件本体
540
1071
  * ------------------------------------------------------------------ */
1072
+ /**
1073
+ * env 插件托管变量名(~/.dsh/env.yml 托管区块内的 key,路径解析与 env 插件
1074
+ * 一致:DSH_ENV_FILE / DSH_HOME 优先)。只提取键名、绝不读值——这些是用户
1075
+ * 明确交给 dsh-env-manager 托管的变量,才是 env:VAR 引用的推荐来源;键行由
1076
+ * env 插件以 yaml 数组渲染(`- key: NAME`),逐行宽容提取即可,不引 YAML 依赖。
1077
+ */
1078
+ function readManagedEnvKeys() {
1079
+ const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), '.dsh');
1080
+ const file = process.env.DSH_ENV_FILE?.trim() || join(dshHome, 'env.yml');
1081
+ try {
1082
+ const lines = readFileSync(file, 'utf8').split('\n');
1083
+ const start = lines.findIndex((line) => line.includes('dsh-env-manager managed'));
1084
+ if (start === -1)
1085
+ return [];
1086
+ const end = lines.findIndex((line, index) => index > start && line.includes('end dsh-env-manager managed'));
1087
+ if (end === -1)
1088
+ return [];
1089
+ const keys = [];
1090
+ for (const line of lines.slice(start + 1, end)) {
1091
+ const match = line.match(/^\s*-\s*key:\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1\s*$/);
1092
+ if (match !== null)
1093
+ keys.push(match[2]);
1094
+ }
1095
+ return [...new Set(keys)].sort().slice(0, 200);
1096
+ }
1097
+ catch {
1098
+ return [];
1099
+ }
1100
+ }
541
1101
  /** HTTP 路由的 loopback 信任围栏(与 dsh-mcp 同思路)。 */
542
1102
  function isLoopbackHttp(req) {
543
1103
  const address = req.socket.remoteAddress;
@@ -606,10 +1166,25 @@ const plugin = definePlugin({
606
1166
  term: config?.term?.trim() || 'xterm-256color',
607
1167
  colorTerm: config?.colorTerm?.trim() || 'truecolor',
608
1168
  cwd: config?.cwd?.trim() || process.cwd(),
1169
+ reconnectGraceSec: typeof config?.reconnectGraceSec === 'number' && Number.isInteger(config.reconnectGraceSec) && config.reconnectGraceSec >= 0 ? config.reconnectGraceSec : DEFAULT_RECONNECT_GRACE_SEC,
609
1170
  sshHosts: Array.isArray(config?.sshHosts) ? config.sshHosts : [],
1171
+ hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
1172
+ shellIntegration: config?.shellIntegration !== false,
1173
+ tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
610
1174
  });
611
1175
  const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS);
612
- const server = new TtyServer(ctx, sessions, live);
1176
+ /** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
1177
+ const persistHostKeys = (records) => {
1178
+ const scope = settingsScope;
1179
+ if (scope === undefined)
1180
+ return;
1181
+ void Promise.resolve(scope.update({ hostKeys: records })).catch((error) => {
1182
+ console.warn('[dsh-tty] 主机密钥记录持久化失败: ' + (error instanceof Error ? error.message : String(error)));
1183
+ });
1184
+ };
1185
+ const hostKeyStore = new HostKeyStore(live, persistHostKeys);
1186
+ const tunnelManager = new TunnelManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore, (bookName) => live.findSshHost(bookName));
1187
+ const server = new TtyServer(ctx, sessions, live, hostKeyStore);
613
1188
  const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false };
614
1189
  let settingsScope;
615
1190
  const snapshot = () => ({
@@ -620,7 +1195,11 @@ const plugin = definePlugin({
620
1195
  term: live.term,
621
1196
  colorTerm: live.colorTerm,
622
1197
  cwd: live.cwd,
1198
+ reconnectGraceSec: Math.round(live.reconnectGraceMs / 1000),
623
1199
  sshHosts: live.sshHosts,
1200
+ hostKeys: live.hostKeys,
1201
+ tunnels: live.tunnels,
1202
+ shellIntegration: live.shellIntegration,
624
1203
  toolsRegistered: stateRef.toolsRegistered,
625
1204
  });
626
1205
  /** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
@@ -630,8 +1209,14 @@ const plugin = definePlugin({
630
1209
  term: typeof section.term === 'string' ? section.term : undefined,
631
1210
  colorTerm: typeof section.colorTerm === 'string' ? section.colorTerm : undefined,
632
1211
  cwd: typeof section.cwd === 'string' ? section.cwd : undefined,
1212
+ reconnectGraceSec: typeof section.reconnectGraceSec === 'number' ? section.reconnectGraceSec : undefined,
633
1213
  sshHosts: sanitizeSshHosts(section.sshHosts),
1214
+ hostKeys: sanitizeHostKeys(section.hostKeys),
1215
+ shellIntegration: typeof section.shellIntegration === 'boolean' ? section.shellIntegration : undefined,
1216
+ tunnels: sanitizeTunnels(section.tunnels),
634
1217
  });
1218
+ // 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
1219
+ tunnelManager.reconcile(live.tunnels);
635
1220
  if (typeof section.maxSessions === 'number' && Number.isInteger(section.maxSessions) && section.maxSessions >= 1 && section.maxSessions <= 16) {
636
1221
  sessions.setLimit(section.maxSessions);
637
1222
  }
@@ -644,7 +1229,7 @@ const plugin = definePlugin({
644
1229
  /** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
645
1230
  const normalizePatch = (input) => {
646
1231
  const patch = {};
647
- const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'sshHosts']);
1232
+ const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration']);
648
1233
  for (const key of Object.keys(input)) {
649
1234
  if (!known.has(key))
650
1235
  return { error: '未知配置项: ' + key };
@@ -665,6 +1250,17 @@ const plugin = definePlugin({
665
1250
  return { error: 'maxSessions 必须是 1~16 的整数' };
666
1251
  patch.maxSessions = value;
667
1252
  }
1253
+ if (input.reconnectGraceSec !== undefined) {
1254
+ const value = Number(input.reconnectGraceSec);
1255
+ if (!Number.isInteger(value) || value < 0 || value > 3600)
1256
+ return { error: 'reconnectGraceSec 必须是 0~3600 的整数' };
1257
+ patch.reconnectGraceSec = value;
1258
+ }
1259
+ if (input.shellIntegration !== undefined) {
1260
+ if (typeof input.shellIntegration !== 'boolean')
1261
+ return { error: 'shellIntegration 必须是布尔值' };
1262
+ patch.shellIntegration = input.shellIntegration;
1263
+ }
668
1264
  for (const key of ['shell', 'term', 'colorTerm']) {
669
1265
  if (input[key] === undefined)
670
1266
  continue;
@@ -689,6 +1285,21 @@ const plugin = definePlugin({
689
1285
  return { error: validated.error };
690
1286
  patch.sshHosts = validated.hosts;
691
1287
  }
1288
+ if (input.hostKeys !== undefined) {
1289
+ const validated = validateHostKeys(input.hostKeys);
1290
+ if (validated.error !== undefined)
1291
+ return { error: validated.error };
1292
+ patch.hostKeys = validated.keys;
1293
+ }
1294
+ if (input.tunnels !== undefined) {
1295
+ // bookName 交叉校验:优先用同次提交的 sshHosts(整体替换语义),否则用现有连接簿
1296
+ const bookSource = Array.isArray(patch.sshHosts) ? patch.sshHosts : live.sshHosts;
1297
+ const bookNames = new Set(bookSource.map((host) => host.name));
1298
+ const validated = validateTunnels(input.tunnels, bookNames);
1299
+ if (validated.error !== undefined)
1300
+ return { error: validated.error };
1301
+ patch.tunnels = validated.tunnels;
1302
+ }
692
1303
  return { patch };
693
1304
  };
694
1305
  // webServer:WS upgrade 路由 + 配置读写路由(/api/dsh-tty/config)
@@ -744,6 +1355,83 @@ const plugin = definePlugin({
744
1355
  writeJson(res, 200, { ok: true, config: snapshot() });
745
1356
  },
746
1357
  }));
1358
+ // ~/.ssh/config 导入候选(连接簿):loopback 围栏,只回解析结果不落盘
1359
+ disposers.push(webServer.register({
1360
+ kind: 'exact',
1361
+ path: '/api/dsh-tty/ssh-config',
1362
+ handler: async (req, res) => {
1363
+ if (!isLoopbackHttp(req)) {
1364
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
1365
+ return;
1366
+ }
1367
+ if (req.method !== 'GET') {
1368
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1369
+ return;
1370
+ }
1371
+ try {
1372
+ const text = readFileSync(expandHome('~/.ssh/config'), 'utf8');
1373
+ writeJson(res, 200, { ok: true, entries: parseSshConfig(text) });
1374
+ }
1375
+ catch (error) {
1376
+ writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/config: ' + (error instanceof Error ? error.message : String(error)) });
1377
+ }
1378
+ },
1379
+ }));
1380
+ // env:VAR 下拉数据源(SSH 对话框):只回 env 插件托管变量名,绝不含值
1381
+ disposers.push(webServer.register({
1382
+ kind: 'exact',
1383
+ path: '/api/dsh-tty/env-vars',
1384
+ handler: async (req, res) => {
1385
+ if (!isLoopbackHttp(req)) {
1386
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
1387
+ return;
1388
+ }
1389
+ if (req.method !== 'GET') {
1390
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1391
+ return;
1392
+ }
1393
+ writeJson(res, 200, { ok: true, names: readManagedEnvKeys() });
1394
+ },
1395
+ }));
1396
+ // known_hosts 指纹导入候选(TOFU 预填充):hashed 条目用连接簿主机名还原
1397
+ disposers.push(webServer.register({
1398
+ kind: 'exact',
1399
+ path: '/api/dsh-tty/known-hosts',
1400
+ handler: async (req, res) => {
1401
+ if (!isLoopbackHttp(req)) {
1402
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
1403
+ return;
1404
+ }
1405
+ if (req.method !== 'GET') {
1406
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1407
+ return;
1408
+ }
1409
+ try {
1410
+ const text = readFileSync(expandHome('~/.ssh/known_hosts'), 'utf8');
1411
+ const candidates = live.sshHosts.flatMap((entry) => [entry.host, `[${entry.host}]:${entry.port}`]);
1412
+ writeJson(res, 200, { ok: true, entries: parseKnownHosts(text, candidates) });
1413
+ }
1414
+ catch (error) {
1415
+ writeJson(res, 200, { ok: false, error: '无法读取 ~/.ssh/known_hosts: ' + (error instanceof Error ? error.message : String(error)) });
1416
+ }
1417
+ },
1418
+ }));
1419
+ // 端口转发隧道实时状态(设置卡片轮询徽标 + tunnel_list 工具数据源)
1420
+ disposers.push(webServer.register({
1421
+ kind: 'exact',
1422
+ path: '/api/dsh-tty/tunnels',
1423
+ handler: async (req, res) => {
1424
+ if (!isLoopbackHttp(req)) {
1425
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
1426
+ return;
1427
+ }
1428
+ if (req.method !== 'GET') {
1429
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1430
+ return;
1431
+ }
1432
+ writeJson(res, 200, { ok: true, tunnels: tunnelManager.list() });
1433
+ },
1434
+ }));
747
1435
  return () => {
748
1436
  server.close();
749
1437
  for (const dispose of disposers) {
@@ -781,9 +1469,20 @@ const plugin = definePlugin({
781
1469
  startup.enabled = false;
782
1470
  if (stored.announceToAgent === false)
783
1471
  startup.announceToAgent = false;
1472
+ if (stored.reconnectGraceSec !== 120 && typeof stored.reconnectGraceSec === 'number' && Number.isInteger(stored.reconnectGraceSec) && stored.reconnectGraceSec >= 0 && stored.reconnectGraceSec <= 3600) {
1473
+ startup.reconnectGraceSec = stored.reconnectGraceSec;
1474
+ }
1475
+ if (stored.shellIntegration === false)
1476
+ startup.shellIntegration = false;
784
1477
  const storedHosts = sanitizeSshHosts(stored.sshHosts);
785
1478
  if (storedHosts !== undefined && storedHosts.length > 0)
786
1479
  startup.sshHosts = storedHosts;
1480
+ const storedKeys = sanitizeHostKeys(stored.hostKeys);
1481
+ if (storedKeys !== undefined && storedKeys.length > 0)
1482
+ startup.hostKeys = storedKeys;
1483
+ const storedTunnels = sanitizeTunnels(stored.tunnels);
1484
+ if (storedTunnels !== undefined && storedTunnels.length > 0)
1485
+ startup.tunnels = storedTunnels;
787
1486
  if (Object.keys(startup).length > 0)
788
1487
  applyPatch(startup);
789
1488
  const events = settingsCtx;
@@ -856,10 +1555,12 @@ const plugin = definePlugin({
856
1555
  })));
857
1556
  disposers.push(tools.register(defineTool({
858
1557
  name: 'tty_capture',
859
- description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出(默认尾部 60 行,最多 500 行)。适合查看用户终端里正在运行的 dev server / watch / 构建输出。',
1558
+ description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)。',
860
1559
  parameters: {
861
1560
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
862
- lines: { type: 'number', description: '读取尾部行数(1~500,默认 60)' },
1561
+ lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
1562
+ last: { type: 'boolean', description: 'true 只返回上一条命令的输出+退出码(默认 false 读尾部)' },
1563
+ raw: { type: 'boolean', description: 'true 返回含 ANSI 转义序列的原始输出(默认 false 清洗为纯文本)' },
863
1564
  },
864
1565
  output: {
865
1566
  schema: {
@@ -868,22 +1569,162 @@ const plugin = definePlugin({
868
1569
  properties: {
869
1570
  sid: { type: 'string', required: true },
870
1571
  tail: { type: 'string', required: true },
1572
+ source: { type: 'string' },
1573
+ exitCode: { type: 'number' },
871
1574
  },
872
1575
  },
873
1576
  render: (_args, value) => {
874
1577
  const v = value;
875
- return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n${v.tail ?? ''}` }];
1578
+ const head = v.source === 'last'
1579
+ ? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
1580
+ : `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
1581
+ return [{ type: 'text', text: head + (v.tail ?? '') }];
876
1582
  },
877
1583
  },
878
1584
  async execute(args) {
879
1585
  const input = args;
880
1586
  if (typeof input.sid !== 'string' || input.sid === '')
881
1587
  throw new Error('sid 必须是非空字符串');
1588
+ const session = sessions.get(input.sid);
1589
+ if (session === undefined || session.closed)
1590
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
1591
+ const useRaw = input.raw === true;
1592
+ if (input.last === true) {
1593
+ const last = session.shellState.lastCommand;
1594
+ if (last === null) {
1595
+ throw new Error('暂无「上一条命令」记录(shell 集成未生效——shell 不受支持或被配置关闭——或尚未执行过命令);可改用 lines 读尾部');
1596
+ }
1597
+ return { sid: input.sid, source: 'last', exitCode: last.exitCode ?? undefined, tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(0, 128 * 1024) };
1598
+ }
882
1599
  const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
1600
+ const rawTail = tailLines(session, lines);
1601
+ return { sid: input.sid, source: 'tail', tail: useRaw ? rawTail : cleanAnsiTail(rawTail) };
1602
+ },
1603
+ })));
1604
+ disposers.push(tools.register(defineTool({
1605
+ name: 'tty_screen',
1606
+ description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
1607
+ parameters: {
1608
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
1609
+ },
1610
+ output: {
1611
+ schema: {
1612
+ type: 'object',
1613
+ additionalProperties: false,
1614
+ properties: {
1615
+ sid: { type: 'string', required: true },
1616
+ cols: { type: 'number', required: true },
1617
+ rows: { type: 'number', required: true },
1618
+ text: { type: 'string', required: true },
1619
+ },
1620
+ },
1621
+ render: (_args, value) => {
1622
+ const v = value;
1623
+ return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')}):\n\n${v.text ?? ''}` }];
1624
+ },
1625
+ },
1626
+ async execute(args) {
1627
+ const input = args;
1628
+ if (typeof input.sid !== 'string' || input.sid === '')
1629
+ throw new Error('sid 必须是非空字符串');
883
1630
  const session = sessions.get(input.sid);
884
1631
  if (session === undefined || session.closed)
885
1632
  throw new Error(`会话不存在或已退出: ${input.sid}`);
886
- return { sid: input.sid, tail: tailLines(session, lines) };
1633
+ const screen = session.screen;
1634
+ if (screen === null)
1635
+ throw new Error(`虚拟屏不可用: ${input.sid}`);
1636
+ const buffer = screen.buffer.active;
1637
+ const lines = [];
1638
+ for (let row = 0; row < screen.rows; row++) {
1639
+ lines.push(buffer.getLine(row)?.translateToString(true) ?? '');
1640
+ }
1641
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '')
1642
+ lines.pop();
1643
+ return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(0, 32 * 1024) };
1644
+ },
1645
+ })));
1646
+ disposers.push(tools.register(defineTool({
1647
+ name: 'tty_expect',
1648
+ description: '在某个终端面板会话(tty_list 提供 sid)的后续输出中等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。匹配到立即返回 matched:true 与周边输出;超时不抛错,返回 matched:false + 尾部输出供判断重试或放弃;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。',
1649
+ parameters: {
1650
+ sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
1651
+ pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
1652
+ timeoutSec: { type: 'number', description: '等待秒数(1~600,默认 30)' },
1653
+ },
1654
+ output: {
1655
+ schema: {
1656
+ type: 'object',
1657
+ additionalProperties: false,
1658
+ properties: {
1659
+ matched: { type: 'boolean', required: true },
1660
+ timedOut: { type: 'boolean', required: true },
1661
+ text: { type: 'string', required: true },
1662
+ exitCode: { type: 'number' },
1663
+ },
1664
+ },
1665
+ render: (_args, value) => {
1666
+ const v = value;
1667
+ if (v.matched === true)
1668
+ return [{ type: 'text', text: `已匹配到等待的模式:\n\n${v.text ?? ''}` }];
1669
+ const why = v.timedOut === true ? '等待超时' : `命令已结束(exitCode=${String(v.exitCode ?? '?')})但未出现匹配`;
1670
+ return [{ type: 'text', text: `${why}。尾部输出:\n\n${v.text ?? ''}` }];
1671
+ },
1672
+ },
1673
+ async execute(args) {
1674
+ const input = args;
1675
+ if (typeof input.sid !== 'string' || input.sid === '')
1676
+ throw new Error('sid 必须是非空字符串');
1677
+ if (typeof input.pattern !== 'string' || input.pattern === '')
1678
+ throw new Error('pattern 必须是非空字符串');
1679
+ const session = sessions.get(input.sid);
1680
+ if (session === undefined || session.closed)
1681
+ throw new Error(`会话不存在或已退出: ${input.sid}`);
1682
+ let re;
1683
+ try {
1684
+ re = new RegExp(input.pattern);
1685
+ }
1686
+ catch (error) {
1687
+ throw new Error('pattern 不是合法的正则表达式: ' + (error instanceof Error ? error.message : String(error)));
1688
+ }
1689
+ const timeoutSec = Math.max(1, Math.min(600, typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) && input.timeoutSec >= 1 ? input.timeoutSec : 30));
1690
+ const timeoutMs = timeoutSec * 1000;
1691
+ return await new Promise((resolve) => {
1692
+ const startedAt = Date.now();
1693
+ const startedInCommand = session.shellState.inCommand;
1694
+ let acc = '';
1695
+ let settled = false;
1696
+ const decoder = new StringDecoder('utf8');
1697
+ const output = session.handle.output;
1698
+ const finish = (result) => {
1699
+ if (settled)
1700
+ return;
1701
+ settled = true;
1702
+ clearTimeout(timer);
1703
+ output.off('data', onData);
1704
+ resolve(result);
1705
+ };
1706
+ const onData = (chunk) => {
1707
+ acc += decoder.write(chunk);
1708
+ const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
1709
+ if (re.test(hay)) {
1710
+ finish({ matched: true, timedOut: false, text: cleanAnsiTail(hay.slice(-6 * 1024)) });
1711
+ return;
1712
+ }
1713
+ // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
1714
+ const state = session.shellState;
1715
+ if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
1716
+ finish({ matched: false, timedOut: false, exitCode: state.lastCommand.exitCode ?? undefined, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
1717
+ }
1718
+ };
1719
+ const timer = setTimeout(() => {
1720
+ finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
1721
+ }, timeoutMs);
1722
+ timer.unref?.();
1723
+ output.on('data', onData);
1724
+ void session.handle.done.then(() => {
1725
+ finish({ matched: false, timedOut: false, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
1726
+ });
1727
+ });
887
1728
  },
888
1729
  })));
889
1730
  disposers.push(tools.register(defineTool({
@@ -920,8 +1761,53 @@ const plugin = definePlugin({
920
1761
  return { ok: true, sent: input.data.length };
921
1762
  },
922
1763
  })));
1764
+ disposers.push(tools.register(defineTool({
1765
+ name: 'tunnel_list',
1766
+ description: '列出端口转发隧道及其实时状态(活跃/连接中/错误/停止、规则、当前与累计连接数、最近错误)。用户说「隧道连不上 / 转发挂了 / 端口转发不通」时先用它诊断;隧道在 设置 → 插件 → 终端面板 卡片维护。',
1767
+ parameters: {},
1768
+ output: {
1769
+ schema: {
1770
+ type: 'object',
1771
+ additionalProperties: false,
1772
+ properties: {
1773
+ tunnels: {
1774
+ type: 'array',
1775
+ required: true,
1776
+ items: {
1777
+ type: 'object',
1778
+ additionalProperties: false,
1779
+ properties: {
1780
+ name: { type: 'string', required: true },
1781
+ direction: { type: 'string', required: true },
1782
+ rule: { type: 'string', required: true },
1783
+ bookName: { type: 'string', required: true },
1784
+ state: { type: 'string', required: true },
1785
+ error: { type: 'string' },
1786
+ connections: { type: 'number', required: true },
1787
+ totalConnections: { type: 'number', required: true },
1788
+ },
1789
+ },
1790
+ },
1791
+ },
1792
+ },
1793
+ render: (_args, value) => {
1794
+ const v = value;
1795
+ const tunnels = v.tunnels ?? [];
1796
+ if (tunnels.length === 0)
1797
+ return [{ type: 'text', text: '当前没有配置端口转发隧道(设置 → 插件 → 终端面板 卡片可添加)' }];
1798
+ const text = '端口转发隧道:' + tunnels.map((t) => {
1799
+ const tail = t.error !== null && t.error !== undefined ? `(错误: ${t.error})` : t.lastForwardError !== null && t.lastForwardError !== undefined ? `(最近转发失败: ${t.lastForwardError})` : `(连接 ${String(t.connections)})`;
1800
+ return `\n- ${t.name} [${t.direction}] ${t.rule} — ${t.state}${tail}`;
1801
+ }).join('');
1802
+ return [{ type: 'text', text }];
1803
+ },
1804
+ },
1805
+ async execute() {
1806
+ return { tunnels: tunnelManager.list().map((t) => ({ ...t, error: t.error ?? undefined })) };
1807
+ },
1808
+ })));
923
1809
  stateRef.toolsRegistered = true;
924
- console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_send)');
1810
+ console.log('[dsh-tty] agent tools registered (tty_list, tty_capture, tty_screen, tty_expect, tty_send, tunnel_list)');
925
1811
  return () => {
926
1812
  stateRef.toolsRegistered = false;
927
1813
  for (const dispose of disposers) {
@@ -938,19 +1824,44 @@ const plugin = definePlugin({
938
1824
  else {
939
1825
  console.log('[dsh-tty] tools service unavailable; agent tools skipped');
940
1826
  }
941
- // 向 agent 公告终端面板能力
1827
+ // 向 agent 公告终端面板能力(静态 section)+ 每轮注入活跃会话快照(动态 context)
942
1828
  if (config?.announceToAgent !== false) {
943
1829
  ctx.inject(['systemPrompt'], (promptCtx) => {
944
1830
  promptCtx.effect(() => {
945
1831
  const systemPrompt = promptCtx.systemPrompt;
946
- return systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
1832
+ const contextDisposable = systemPrompt.context({
1833
+ name: 'plugin:dsh-tty:terminals',
1834
+ order: 150,
1835
+ text: () => {
1836
+ const list = sessions.list();
1837
+ if (list.length === 0)
1838
+ return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
1839
+ return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
1840
+ const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
1841
+ return `- sid=${s.sid} [${s.kind}] ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
1842
+ }).join('\n');
1843
+ },
1844
+ });
1845
+ const sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
1846
+ return () => {
1847
+ sectionDisposable();
1848
+ contextDisposable();
1849
+ };
947
1850
  }, 'dsh-tty: announcement');
948
1851
  });
949
1852
  }
950
- // 插件卸载时回收全部会话
1853
+ // 孤儿会话回收器:超过保活期的异常断开会话定期清理(grace=0 时为 no-op,
1854
+ // 断开时立即结束);插件卸载时随 effect 一起停掉
1855
+ const reaperTimer = setInterval(() => {
1856
+ void sessions.reapOrphans(live.reconnectGraceMs);
1857
+ }, REAPER_INTERVAL_MS);
1858
+ reaperTimer.unref?.();
1859
+ ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');
1860
+ // 插件卸载时回收全部会话与隧道
951
1861
  ctx.effect(() => {
952
1862
  return () => {
953
1863
  void sessions.disposeAll();
1864
+ tunnelManager.disposeAll();
954
1865
  };
955
1866
  }, 'dsh-tty: session cleanup');
956
1867
  console.log(`[dsh-tty] mounted (shell=${live.shell}, term=${live.term}, cwd=${live.cwd}, maxSessions=${sessions.limitValue})`);