@hyzyn/dsh-tty 0.9.0 → 0.10.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/LICENSE +214 -21
- package/README.md +77 -10
- package/client.js +32 -58
- package/lib/index.d.ts +24 -7
- package/lib/index.js +432 -112
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.d.ts +11 -0
- package/lib/shell-integration.js +182 -48
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +24 -0
- package/lib/ssh.js +114 -2
- package/lib/ssh.js.map +1 -1
- package/lib/tmux.d.ts +48 -0
- package/lib/tmux.js +176 -0
- package/lib/tmux.js.map +1 -0
- package/package.json +26 -10
package/lib/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import { parseSshConfig } from './ssh-config.js';
|
|
|
20
20
|
import { parseKnownHosts } from './known-hosts.js';
|
|
21
21
|
import { TunnelManager } from './tunnels.js';
|
|
22
22
|
import { SftpManager } from './sftp.js';
|
|
23
|
+
import { buildTmuxSpawnPlan, ensureTmuxAssets, killTmuxSession, listTmuxSessions, probeTmux, refreshTmuxClient, sanitizePersistName } from './tmux.js';
|
|
23
24
|
const SSH_HOST_SCHEMA = z.object({
|
|
24
25
|
name: z.string(),
|
|
25
26
|
host: z.string(),
|
|
@@ -30,6 +31,8 @@ const SSH_HOST_SCHEMA = z.object({
|
|
|
30
31
|
passphrase: z.string().default(''),
|
|
31
32
|
password: z.string().default(''),
|
|
32
33
|
agentForward: z.boolean().default(false),
|
|
34
|
+
/** 该条目的 SSH 标签默认以 tmux 持久会话打开(仅 persistence=tmux 时生效)。 */
|
|
35
|
+
persist: z.boolean().default(false),
|
|
33
36
|
});
|
|
34
37
|
const HOST_KEY_SCHEMA = z.object({
|
|
35
38
|
host: z.string(),
|
|
@@ -62,6 +65,9 @@ const TTY_SETTINGS_SCHEMA = z.object({
|
|
|
62
65
|
tunnels: z.array(TUNNEL_SCHEMA).default([]),
|
|
63
66
|
shellIntegration: z.boolean().default(true),
|
|
64
67
|
sftpStyle: z.union([z.const('dialog'), z.const('dual')]).default('dialog'),
|
|
68
|
+
persistence: z.union([z.const('off'), z.const('tmux')]).default('off'),
|
|
69
|
+
endOnPageClose: z.boolean().default(false),
|
|
70
|
+
persistSessions: z.array(z.object({ tmuxName: z.string() })).default([]),
|
|
65
71
|
});
|
|
66
72
|
/* ------------------------------------------------------------------ *
|
|
67
73
|
* 常量
|
|
@@ -79,7 +85,7 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
79
85
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
80
86
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
81
87
|
const REAPER_INTERVAL_MS = 10_000;
|
|
82
|
-
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU
|
|
88
|
+
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)应引导用户到终端面板里运行,不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
83
89
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
84
90
|
function wrapLocalPty(handle) {
|
|
85
91
|
let resizeWarned = false;
|
|
@@ -131,6 +137,12 @@ class LiveConfig {
|
|
|
131
137
|
shellIntegration;
|
|
132
138
|
/** 端口转发隧道规格。 */
|
|
133
139
|
tunnels;
|
|
140
|
+
/** 会话持久化模式(off / tmux)。 */
|
|
141
|
+
persistence;
|
|
142
|
+
/** 页面断开且保活期结束时是否结束 tmux 持久会话(默认 false = 留存)。 */
|
|
143
|
+
endOnPageClose;
|
|
144
|
+
/** SSH 持久会话名(远程 tmux 托管;本机 socket 清单看不到,随 settings 留存)。 */
|
|
145
|
+
persistSessions;
|
|
134
146
|
constructor(init) {
|
|
135
147
|
this.shell = init.shell;
|
|
136
148
|
this.term = sanitizeTermValue(init.term, 'xterm-256color');
|
|
@@ -141,6 +153,9 @@ class LiveConfig {
|
|
|
141
153
|
this.hostKeys = init.hostKeys ?? [];
|
|
142
154
|
this.shellIntegration = init.shellIntegration;
|
|
143
155
|
this.tunnels = init.tunnels ?? [];
|
|
156
|
+
this.persistence = init.persistence === 'tmux' ? 'tmux' : 'off';
|
|
157
|
+
this.endOnPageClose = init.endOnPageClose === true;
|
|
158
|
+
this.persistSessions = init.persistSessions ?? [];
|
|
144
159
|
}
|
|
145
160
|
/** 合并部分更新;空字符串/undefined 保持原值;sshHosts/hostKeys/tunnels 传数组即整体替换。 */
|
|
146
161
|
apply(partial) {
|
|
@@ -163,6 +178,12 @@ class LiveConfig {
|
|
|
163
178
|
this.shellIntegration = partial.shellIntegration;
|
|
164
179
|
if (Array.isArray(partial.tunnels))
|
|
165
180
|
this.tunnels = partial.tunnels;
|
|
181
|
+
if (partial.persistence === 'tmux' || partial.persistence === 'off')
|
|
182
|
+
this.persistence = partial.persistence;
|
|
183
|
+
if (typeof partial.endOnPageClose === 'boolean')
|
|
184
|
+
this.endOnPageClose = partial.endOnPageClose;
|
|
185
|
+
if (Array.isArray(partial.persistSessions))
|
|
186
|
+
this.persistSessions = partial.persistSessions;
|
|
166
187
|
}
|
|
167
188
|
findSshHost(name) {
|
|
168
189
|
return this.sshHosts.find((entry) => entry.name === name);
|
|
@@ -207,8 +228,11 @@ function cleanAnsiTail(raw) {
|
|
|
207
228
|
return idx === -1 ? line : line.slice(idx + 1);
|
|
208
229
|
}).join('\n');
|
|
209
230
|
}
|
|
210
|
-
/** OSC 133 命令标记帧:\x1b]133;<A|B|D>[;<
|
|
211
|
-
|
|
231
|
+
/** OSC 133 命令标记帧:\x1b]133;<A|B|D|T>[;<payload>](BEL|ST)。
|
|
232
|
+
* T(0.10.0)= tmux 持久标签的 pane 内容快照(base64):tmux 的 pane 重画是
|
|
233
|
+
* 异步批量的,命令输出会落在 D 标记之后逃出 B..D 捕获窗口,故由钩子在发 D
|
|
234
|
+
* 前 capture-pane 随流直送,宿主优先采用。 */
|
|
235
|
+
const OSC133_RE = /\x1b\]133;([ABDCT])(?:;([^\x07\x1b]*))?(?:\x07|\x1b\\)/g;
|
|
212
236
|
/** OSC 7 cwd 上报帧:\x1b]7;file://<host><path>(BEL|ST)。 */
|
|
213
237
|
const OSC7_RE = /\x1b\]7;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
214
238
|
/** 单条命令输出捕获上限(环形,超出丢头部)。 */
|
|
@@ -218,7 +242,14 @@ const FLUSH_INTERVAL_MS = 12;
|
|
|
218
242
|
/** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
|
|
219
243
|
const FLUSH_SIZE_CHARS = 64 * 1024;
|
|
220
244
|
function createShellState() {
|
|
221
|
-
return { carry: '', inCommand: false, cmdBuffer: '', lastCommand: null };
|
|
245
|
+
return { carry: '', inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null };
|
|
246
|
+
}
|
|
247
|
+
/** OSC 133;T 的 base64 payload → utf8 文本(无效输入返回 null)。 */
|
|
248
|
+
function decodeBase64Utf8(payload) {
|
|
249
|
+
if (payload === undefined || payload === '')
|
|
250
|
+
return null;
|
|
251
|
+
const text = Buffer.from(payload, 'base64').toString('utf8');
|
|
252
|
+
return text !== '' ? text : null;
|
|
222
253
|
}
|
|
223
254
|
/** OSC 7 body(file://host/path)→ 解码后的路径;解析失败返回 undefined。 */
|
|
224
255
|
function osc7Path(body) {
|
|
@@ -248,11 +279,11 @@ function feedShellIntegration(session, text) {
|
|
|
248
279
|
if (lastOpen !== -1) {
|
|
249
280
|
const tail = data.slice(lastOpen);
|
|
250
281
|
if (!/\x07|\x1b\\/.test(tail)) {
|
|
251
|
-
if (tail.length <=
|
|
282
|
+
if (tail.length <= 64 * 1024) {
|
|
252
283
|
state.carry = tail;
|
|
253
284
|
data = data.slice(0, lastOpen);
|
|
254
285
|
}
|
|
255
|
-
// 超过
|
|
286
|
+
// 超过 64KB 仍不闭合视为垃圾:放弃扣留,整块照常处理(上限容纳 T 快照)
|
|
256
287
|
}
|
|
257
288
|
}
|
|
258
289
|
for (const match of data.matchAll(OSC7_RE)) {
|
|
@@ -271,17 +302,22 @@ function feedShellIntegration(session, text) {
|
|
|
271
302
|
if (kind === 'B') {
|
|
272
303
|
state.inCommand = true;
|
|
273
304
|
state.cmdBuffer = '';
|
|
305
|
+
state.pendingT = null;
|
|
306
|
+
}
|
|
307
|
+
else if (kind === 'T') {
|
|
308
|
+
state.pendingT = decodeBase64Utf8(match[2]);
|
|
274
309
|
}
|
|
275
310
|
else if (kind === 'D') {
|
|
276
311
|
if (state.inCommand) {
|
|
277
|
-
const exitCode = match[2] !== undefined ? Number(match[2]) : null;
|
|
312
|
+
const exitCode = match[2] !== undefined && /^\d+$/.test(match[2]) ? Number(match[2]) : null;
|
|
278
313
|
state.lastCommand = {
|
|
279
|
-
output: state.cmdBuffer.slice(-COMMAND_CAP),
|
|
314
|
+
output: (state.pendingT ?? state.cmdBuffer).slice(-COMMAND_CAP),
|
|
280
315
|
exitCode: exitCode !== null && Number.isFinite(exitCode) ? exitCode : null,
|
|
281
316
|
endedAt: Date.now(),
|
|
282
317
|
};
|
|
283
318
|
state.inCommand = false;
|
|
284
319
|
state.cmdBuffer = '';
|
|
320
|
+
state.pendingT = null;
|
|
285
321
|
}
|
|
286
322
|
}
|
|
287
323
|
// A(prompt 开始)无需记录
|
|
@@ -400,6 +436,7 @@ function sanitizeSshHosts(input) {
|
|
|
400
436
|
passphrase: typeof raw.passphrase === 'string' ? raw.passphrase : '',
|
|
401
437
|
password: typeof raw.password === 'string' ? raw.password : '',
|
|
402
438
|
agentForward: raw.agentForward === true,
|
|
439
|
+
persist: raw.persist === true,
|
|
403
440
|
});
|
|
404
441
|
}
|
|
405
442
|
return out;
|
|
@@ -435,6 +472,9 @@ function validateSshHosts(input) {
|
|
|
435
472
|
if (raw.agentForward !== undefined && typeof raw.agentForward !== 'boolean') {
|
|
436
473
|
return { error: 'sshHosts.agentForward 必须是布尔值' };
|
|
437
474
|
}
|
|
475
|
+
if (raw.persist !== undefined && typeof raw.persist !== 'boolean') {
|
|
476
|
+
return { error: 'sshHosts.persist 必须是布尔值' };
|
|
477
|
+
}
|
|
438
478
|
if ((raw.auth === 'key') && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
|
|
439
479
|
return { error: `sshHosts「${String(raw.name)}」auth=key 需要 keyPath` };
|
|
440
480
|
}
|
|
@@ -486,6 +526,20 @@ function validateHostKeys(input) {
|
|
|
486
526
|
}
|
|
487
527
|
return { keys: sanitizeHostKeys(input) };
|
|
488
528
|
}
|
|
529
|
+
/** 宽松清洗一份 persistSessions(内部状态:SSH 持久会话名);非法条目丢弃。 */
|
|
530
|
+
function sanitizePersistSessions(input) {
|
|
531
|
+
if (!Array.isArray(input))
|
|
532
|
+
return undefined;
|
|
533
|
+
const out = [];
|
|
534
|
+
for (const item of input) {
|
|
535
|
+
if (typeof item !== 'object' || item === null)
|
|
536
|
+
continue;
|
|
537
|
+
const name = item.tmuxName;
|
|
538
|
+
if (typeof name === 'string' && /^dsh-[A-Za-z0-9_-]{1,64}$/.test(name) && !out.includes(name))
|
|
539
|
+
out.push(name);
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
489
543
|
/**
|
|
490
544
|
* TOFU 主机指纹存储:get/record 面向 spawnSsh 的 hostVerifier;
|
|
491
545
|
* record 时经 persist 回调写入 settings(宿主重启后钉扎仍在)。
|
|
@@ -547,8 +601,12 @@ function isLoopbackUpgrade(req) {
|
|
|
547
601
|
class SessionManager {
|
|
548
602
|
sessions = new Map();
|
|
549
603
|
limit;
|
|
550
|
-
|
|
604
|
+
/** 回收器销毁孤儿时是否连 tmux 持久会话一起结束(endOnPageClose 策略)。 */
|
|
605
|
+
endTmuxOnReap = () => false;
|
|
606
|
+
constructor(maxSessions, endTmuxOnReap) {
|
|
551
607
|
this.limit = maxSessions;
|
|
608
|
+
if (endTmuxOnReap !== undefined)
|
|
609
|
+
this.endTmuxOnReap = endTmuxOnReap;
|
|
552
610
|
}
|
|
553
611
|
get limitValue() {
|
|
554
612
|
return this.limit;
|
|
@@ -572,7 +630,7 @@ class SessionManager {
|
|
|
572
630
|
get(id) {
|
|
573
631
|
return this.sessions.get(id);
|
|
574
632
|
}
|
|
575
|
-
/** 会话的只读快照(SSH 会话无本地 pid
|
|
633
|
+
/** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist)。 */
|
|
576
634
|
snapshotOf(session) {
|
|
577
635
|
const base = {
|
|
578
636
|
sid: session.id,
|
|
@@ -581,6 +639,7 @@ class SessionManager {
|
|
|
581
639
|
target: session.target,
|
|
582
640
|
startedAt: session.startedAt,
|
|
583
641
|
lastOutputAt: session.lastOutputAt,
|
|
642
|
+
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
584
643
|
};
|
|
585
644
|
return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
|
|
586
645
|
}
|
|
@@ -592,9 +651,17 @@ class SessionManager {
|
|
|
592
651
|
listForAttach() {
|
|
593
652
|
return [...this.sessions.values()].map((session) => ({
|
|
594
653
|
...this.snapshotOf(session),
|
|
595
|
-
attachable: session.
|
|
654
|
+
attachable: session.clients.size === 0 && !session.closed,
|
|
596
655
|
}));
|
|
597
656
|
}
|
|
657
|
+
/** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
|
|
658
|
+
findByTmuxName(tmuxName) {
|
|
659
|
+
for (const session of this.sessions.values()) {
|
|
660
|
+
if (session.tmuxName === tmuxName && !session.closed)
|
|
661
|
+
return session;
|
|
662
|
+
}
|
|
663
|
+
return undefined;
|
|
664
|
+
}
|
|
598
665
|
/** 同步退役:移出全局表 + 释放虚拟屏(幂等,不杀进程)。 */
|
|
599
666
|
retire(session) {
|
|
600
667
|
session.closed = true;
|
|
@@ -606,9 +673,18 @@ class SessionManager {
|
|
|
606
673
|
/* 已释放 */
|
|
607
674
|
}
|
|
608
675
|
}
|
|
609
|
-
/** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。
|
|
676
|
+
/** 释放并销毁会话:退役 + 树级终止(等待 terminate 完成,最慢 ~20s)。
|
|
677
|
+
* endOnPageClose 策略下,回收器销毁孤儿时连 tmux 持久会话一起结束。 */
|
|
610
678
|
async destroy(session) {
|
|
611
679
|
this.retire(session);
|
|
680
|
+
if (session.tmuxName !== null && this.endTmuxOnReap()) {
|
|
681
|
+
try {
|
|
682
|
+
await session.handle.tmuxTeardown?.();
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
/* tmux 收尾失败不阻断回收 */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
612
688
|
await forceKill(session.handle);
|
|
613
689
|
}
|
|
614
690
|
/** 回收超过保活期的孤儿会话(回收器定时调用;graceMs<=0 时不动作)。 */
|
|
@@ -645,12 +721,18 @@ class TtyServer {
|
|
|
645
721
|
sessions;
|
|
646
722
|
options;
|
|
647
723
|
hostKeyStore;
|
|
724
|
+
trackPersist;
|
|
648
725
|
wss = new WebSocketServer({ noServer: true });
|
|
649
|
-
|
|
726
|
+
/** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
|
|
727
|
+
pendingTmux = new Map();
|
|
728
|
+
constructor(ctx, sessions, options, hostKeyStore,
|
|
729
|
+
/** SSH 持久会话名留存回调(apply 闭包实现,settings 落盘)。 */
|
|
730
|
+
trackPersist) {
|
|
650
731
|
this.ctx = ctx;
|
|
651
732
|
this.sessions = sessions;
|
|
652
733
|
this.options = options;
|
|
653
734
|
this.hostKeyStore = hostKeyStore;
|
|
735
|
+
this.trackPersist = trackPersist;
|
|
654
736
|
this.wss.on('connection', (ws) => this.onConnection(ws));
|
|
655
737
|
}
|
|
656
738
|
/** registerUpgrade 的 handler(loopback 围栏 + ws 握手)。 */
|
|
@@ -667,16 +749,21 @@ class TtyServer {
|
|
|
667
749
|
/** 本连接上的会话表(sid → session);单连接多会话(标签页)。 */
|
|
668
750
|
const local = new Map();
|
|
669
751
|
const cleanupAll = async () => {
|
|
670
|
-
const all = [...local.
|
|
752
|
+
const all = [...local.entries()];
|
|
671
753
|
local.clear();
|
|
672
|
-
await Promise.all(all.map(async (session) => {
|
|
754
|
+
await Promise.all(all.map(async ([clientSid, session]) => {
|
|
673
755
|
if (session.closed)
|
|
674
756
|
return;
|
|
757
|
+
// 解绑本连接的客户端;其余窗口仍绑定着(跨连接共享)时会话继续在线
|
|
758
|
+
session.clients.delete(clientSid);
|
|
759
|
+
if (session.clients.size > 0) {
|
|
760
|
+
this.flushPendingOutput(session);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
675
763
|
if (this.options.reconnectGraceMs > 0) {
|
|
676
764
|
// 客户端正常关面板会先逐个 kill(会话已移出 local),走到这里的都是
|
|
677
765
|
// 「异常断开仍有存活会话」:转孤儿保活,等待新连接 attach,到点由回收器清理
|
|
678
766
|
this.flushPendingOutput(session); // 没了收件人,待发帧直接丢弃(回放走环形缓冲)
|
|
679
|
-
session.ws = null;
|
|
680
767
|
session.orphanedAt = Date.now();
|
|
681
768
|
return;
|
|
682
769
|
}
|
|
@@ -724,13 +811,66 @@ class TtyServer {
|
|
|
724
811
|
send(ws, { t: 'error', m: local.size === 0 ? '没有可用会话(先发 spawn)' : '存在多个会话,请指定 sid' });
|
|
725
812
|
return undefined;
|
|
726
813
|
}
|
|
814
|
+
/** 把一个客户端连接重绑定到既有会话(跨窗口共享 / 并发恢复收敛共用)。 */
|
|
815
|
+
rebindClient(session, sid, ws, local) {
|
|
816
|
+
session.clients.set(sid, ws);
|
|
817
|
+
session.orphanedAt = null;
|
|
818
|
+
if (session.paused) {
|
|
819
|
+
session.paused = false;
|
|
820
|
+
try {
|
|
821
|
+
session.handle.output.resume();
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
/* 已退出 */
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
local.set(sid, session);
|
|
828
|
+
send(ws, {
|
|
829
|
+
t: 'ready',
|
|
830
|
+
sid,
|
|
831
|
+
pid: session.handle.pid,
|
|
832
|
+
kind: session.kind,
|
|
833
|
+
target: session.target !== '' ? session.target : undefined,
|
|
834
|
+
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
835
|
+
});
|
|
836
|
+
if (session.tmuxName !== null)
|
|
837
|
+
void session.handle.tmuxRefresh?.();
|
|
838
|
+
}
|
|
839
|
+
/** 等待同 tmuxName 的在途创建完成;返回可重绑定的会话(null = 无在途/已失败)。 */
|
|
840
|
+
async waitPendingTmux(tmuxName) {
|
|
841
|
+
const inflight = this.pendingTmux.get(tmuxName);
|
|
842
|
+
if (inflight === undefined)
|
|
843
|
+
return null;
|
|
844
|
+
try {
|
|
845
|
+
return await inflight;
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
727
851
|
/**
|
|
728
852
|
* 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
|
|
729
853
|
* 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
|
|
730
854
|
* 可拖 ~20s)由 terminate 在后台继续收尾,不阻塞 kill 帧处理。
|
|
855
|
+
* tmux 背书会话先向 tmux server 发 kill-session(杀客户端只会 detach,
|
|
856
|
+
* 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
|
|
731
857
|
*/
|
|
732
858
|
killSessionNow(session) {
|
|
733
859
|
this.sessions.retire(session);
|
|
860
|
+
const teardown = session.handle.tmuxTeardown;
|
|
861
|
+
if (teardown !== undefined) {
|
|
862
|
+
let settled = false;
|
|
863
|
+
const finish = () => {
|
|
864
|
+
if (settled)
|
|
865
|
+
return;
|
|
866
|
+
settled = true;
|
|
867
|
+
void forceKill(session.handle);
|
|
868
|
+
};
|
|
869
|
+
const timer = setTimeout(finish, 2500);
|
|
870
|
+
timer.unref?.();
|
|
871
|
+
void teardown().catch(() => { }).then(finish);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
734
874
|
try {
|
|
735
875
|
session.handle.forceKill?.();
|
|
736
876
|
}
|
|
@@ -761,8 +901,25 @@ class TtyServer {
|
|
|
761
901
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
762
902
|
return;
|
|
763
903
|
}
|
|
904
|
+
// 持久化(0.10.0):配置 persistence=tmux 且帧带 persist 时,spawn 包装层
|
|
905
|
+
// 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
|
|
906
|
+
// 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
|
|
907
|
+
// 宿主重启后重开标签按同名 attach 回原 tmux 会话
|
|
908
|
+
const persistName = msg.persist === true && this.options.persistence === 'tmux'
|
|
909
|
+
? sanitizePersistName(msg.persistName, sid)
|
|
910
|
+
: null;
|
|
911
|
+
// 跨窗口共享(0.10.1):同 tmuxName 的宿主会话还活着(别的窗口接回过,
|
|
912
|
+
// 或同刻并发恢复的在途创建)时不再新建 PTY,本连接重绑定到它——
|
|
913
|
+
// 单 PTY 多客户端扇出,名额不翻倍
|
|
914
|
+
if (persistName !== null) {
|
|
915
|
+
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
916
|
+
if (existing !== null) {
|
|
917
|
+
this.rebindClient(existing, sid, ws, local);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
764
921
|
if (!this.sessions.canSpawn()) {
|
|
765
|
-
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue}
|
|
922
|
+
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})——每个窗口的每个标签各占一个名额:关闭不用的窗口/标签,或在设置卡片调大「并发会话上限」` });
|
|
766
923
|
return;
|
|
767
924
|
}
|
|
768
925
|
// 客户端(当前会话)cwd 优先;校验存在性,避免 node-pty 抛难懂错误
|
|
@@ -776,37 +933,70 @@ class TtyServer {
|
|
|
776
933
|
send(ws, { t: 'error', sid, m: 'subprocess 服务不可用' });
|
|
777
934
|
return;
|
|
778
935
|
}
|
|
779
|
-
const
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
936
|
+
const wantsPersist = persistName !== null;
|
|
937
|
+
let spawnPlan = buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
|
|
938
|
+
let tmuxName = null;
|
|
939
|
+
if (wantsPersist) {
|
|
940
|
+
const probe = await probeTmux();
|
|
941
|
+
if (probe.available) {
|
|
942
|
+
tmuxName = persistName;
|
|
943
|
+
ensureTmuxAssets({ shell: this.options.shell, colorTerm: this.options.colorTerm, shellIntegration: this.options.shellIntegration, passthrough: probe.passthrough });
|
|
944
|
+
spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
// 在途注册:tmuxName 相同的并发 spawn 等本次创建完成后重绑定(防竞态翻倍)
|
|
948
|
+
const create = (async () => {
|
|
949
|
+
const handle = wrapLocalPty(await subprocess.spawnTerminal({
|
|
950
|
+
argv: spawnPlan.argv,
|
|
951
|
+
rows: Number(msg.rows) || 24,
|
|
952
|
+
cols: Number(msg.cols) || 80,
|
|
953
|
+
cwd,
|
|
954
|
+
env: { TERM: this.options.term, COLORTERM: this.options.colorTerm, ...spawnPlan.env },
|
|
955
|
+
graceMs: 5000,
|
|
956
|
+
}));
|
|
957
|
+
if (tmuxName !== null) {
|
|
958
|
+
handle.tmuxTeardown = () => killTmuxSession(tmuxName);
|
|
959
|
+
handle.tmuxRefresh = () => refreshTmuxClient(tmuxName);
|
|
960
|
+
}
|
|
961
|
+
const next = {
|
|
962
|
+
id: sid,
|
|
963
|
+
handle,
|
|
964
|
+
clients: new Map([[sid, ws]]),
|
|
965
|
+
closed: false,
|
|
966
|
+
paused: false,
|
|
967
|
+
cwd,
|
|
968
|
+
kind: 'local',
|
|
969
|
+
target: '',
|
|
970
|
+
startedAt: Date.now(),
|
|
971
|
+
lastOutputAt: Date.now(),
|
|
972
|
+
buffer: '',
|
|
973
|
+
decoder: new StringDecoder('utf8'),
|
|
974
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
975
|
+
orphanedAt: null,
|
|
976
|
+
shellState: createShellState(),
|
|
977
|
+
pendingOutput: '',
|
|
978
|
+
flushTimer: null,
|
|
979
|
+
tmuxName,
|
|
980
|
+
};
|
|
981
|
+
local.set(sid, next);
|
|
982
|
+
this.sessions.add(next);
|
|
983
|
+
return next;
|
|
984
|
+
})();
|
|
985
|
+
if (tmuxName !== null) {
|
|
986
|
+
const registered = create.catch(() => null);
|
|
987
|
+
this.pendingTmux.set(tmuxName, registered);
|
|
988
|
+
void registered.finally(() => {
|
|
989
|
+
if (this.pendingTmux.get(tmuxName) === registered)
|
|
990
|
+
this.pendingTmux.delete(tmuxName);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
const next = await create;
|
|
994
|
+
send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(tmuxName !== null ? { persist: true } : {}) });
|
|
995
|
+
if (wantsPersist && tmuxName === null) {
|
|
996
|
+
const notice = '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
|
|
997
|
+
next.buffer = (next.buffer + notice).slice(-BUFFER_CAP);
|
|
998
|
+
send(ws, { t: 'data', sid, d: notice });
|
|
999
|
+
}
|
|
810
1000
|
this.attachOutput(next);
|
|
811
1001
|
this.watchDone(next, local);
|
|
812
1002
|
}
|
|
@@ -820,8 +1010,20 @@ class TtyServer {
|
|
|
820
1010
|
send(ws, { t: 'error', sid, m: 'sid 已存在' });
|
|
821
1011
|
return;
|
|
822
1012
|
}
|
|
1013
|
+
// 跨窗口共享(0.10.1):同 tmuxName 的 SSH 持久会话还活着时不重建远程
|
|
1014
|
+
// 连接,本连接重绑定到现有会话(单 PTY 多客户端扇出)
|
|
1015
|
+
const persistName = msg.persist === true && this.options.persistence === 'tmux'
|
|
1016
|
+
? sanitizePersistName(msg.persistName, sid)
|
|
1017
|
+
: null;
|
|
1018
|
+
if (persistName !== null) {
|
|
1019
|
+
const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
|
|
1020
|
+
if (existing !== null && existing.kind === 'ssh' && !existing.closed) {
|
|
1021
|
+
this.rebindClient(existing, sid, ws, local);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
823
1025
|
if (!this.sessions.canSpawn()) {
|
|
824
|
-
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue}
|
|
1026
|
+
send(ws, { t: 'error', sid, m: `会话数已达上限(${this.sessions.limitValue})——每个窗口的每个标签各占一个名额:关闭不用的窗口/标签,或在设置卡片调大「并发会话上限」` });
|
|
825
1027
|
return;
|
|
826
1028
|
}
|
|
827
1029
|
// name 引用连接簿条目作基底,内联字段可逐项覆盖(与 SFTP 路由共用 mergeSshSpec)
|
|
@@ -833,51 +1035,83 @@ class TtyServer {
|
|
|
833
1035
|
const spec = merged.spec;
|
|
834
1036
|
const target = sshTarget(spec);
|
|
835
1037
|
send(ws, { t: 'data', sid, d: `\x1b[2mConnecting ${target} …\x1b[0m\r\n` });
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
1038
|
+
// 持久化(0.10.0):远程 `exec tmux new-session -A` 托管;远程无 tmux 时
|
|
1039
|
+
// spawnSsh 降级普通 shell channel 并经 startupNotice 回灰字提示
|
|
1040
|
+
const wantsPersist = persistName !== null;
|
|
1041
|
+
const persistOpt = wantsPersist ? { name: persistName } : undefined;
|
|
1042
|
+
// 在途注册:同 persistName 的并发恢复等待本次创建完成后重绑定(防竞态翻倍)
|
|
1043
|
+
const create = (async () => {
|
|
1044
|
+
let handle;
|
|
1045
|
+
try {
|
|
1046
|
+
handle = await spawnSsh(spec, {
|
|
1047
|
+
term: this.options.term,
|
|
1048
|
+
cols: Number(msg.cols) || 80,
|
|
1049
|
+
rows: Number(msg.rows) || 24,
|
|
1050
|
+
logger: { info: (m) => this.ctx.logger.info(m), warn: (m) => this.ctx.logger.warn(m) },
|
|
1051
|
+
hostKeyStore: this.hostKeyStore,
|
|
1052
|
+
...(persistOpt !== undefined ? { persist: persistOpt } : {}),
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
send(ws, { t: 'error', sid, m: error instanceof Error ? error.message : String(error) });
|
|
1057
|
+
throw error;
|
|
1058
|
+
}
|
|
1059
|
+
const tmuxName = persistOpt !== undefined && handle.startupNotice === undefined ? persistOpt.name : null;
|
|
1060
|
+
const next = {
|
|
1061
|
+
id: sid,
|
|
1062
|
+
handle,
|
|
1063
|
+
clients: new Map([[sid, ws]]),
|
|
1064
|
+
closed: false,
|
|
1065
|
+
paused: false,
|
|
1066
|
+
cwd: '',
|
|
1067
|
+
kind: 'ssh',
|
|
1068
|
+
target,
|
|
1069
|
+
startedAt: Date.now(),
|
|
1070
|
+
lastOutputAt: Date.now(),
|
|
1071
|
+
buffer: '',
|
|
1072
|
+
decoder: new StringDecoder('utf8'),
|
|
1073
|
+
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
1074
|
+
orphanedAt: null,
|
|
1075
|
+
shellState: createShellState(),
|
|
1076
|
+
pendingOutput: '',
|
|
1077
|
+
flushTimer: null,
|
|
1078
|
+
tmuxName,
|
|
1079
|
+
};
|
|
1080
|
+
local.set(sid, next);
|
|
1081
|
+
this.sessions.add(next);
|
|
1082
|
+
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target, ...(tmuxName !== null ? { persist: true } : {}) });
|
|
1083
|
+
if (tmuxName !== null)
|
|
1084
|
+
this.trackPersist(tmuxName, true); // 留存:远程 tmux 本机清单看不到
|
|
1085
|
+
if (handle.startupNotice !== undefined) {
|
|
1086
|
+
const notice = `\x1b[2m[dsh-tty] ${handle.startupNotice}\x1b[0m\r\n`;
|
|
1087
|
+
next.buffer = (next.buffer + notice).slice(-BUFFER_CAP);
|
|
1088
|
+
send(ws, { t: 'data', sid, d: notice });
|
|
1089
|
+
}
|
|
1090
|
+
this.attachOutput(next);
|
|
1091
|
+
this.watchDone(next, local);
|
|
1092
|
+
return next;
|
|
1093
|
+
})();
|
|
1094
|
+
if (persistName !== null) {
|
|
1095
|
+
const registered = create.catch(() => null);
|
|
1096
|
+
this.pendingTmux.set(persistName, registered);
|
|
1097
|
+
void registered.finally(() => {
|
|
1098
|
+
if (this.pendingTmux.get(persistName) === registered)
|
|
1099
|
+
this.pendingTmux.delete(persistName);
|
|
844
1100
|
});
|
|
845
1101
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1102
|
+
try {
|
|
1103
|
+
await create;
|
|
1104
|
+
}
|
|
1105
|
+
catch {
|
|
1106
|
+
return; // 错误帧已在创建闭包内发送
|
|
849
1107
|
}
|
|
850
|
-
const next = {
|
|
851
|
-
id: sid,
|
|
852
|
-
handle,
|
|
853
|
-
ws,
|
|
854
|
-
closed: false,
|
|
855
|
-
paused: false,
|
|
856
|
-
cwd: '',
|
|
857
|
-
kind: 'ssh',
|
|
858
|
-
target,
|
|
859
|
-
startedAt: Date.now(),
|
|
860
|
-
lastOutputAt: Date.now(),
|
|
861
|
-
buffer: '',
|
|
862
|
-
decoder: new StringDecoder('utf8'),
|
|
863
|
-
screen: this.createScreen(Number(msg.cols) || 80, Number(msg.rows) || 24),
|
|
864
|
-
orphanedAt: null,
|
|
865
|
-
shellState: createShellState(),
|
|
866
|
-
pendingOutput: '',
|
|
867
|
-
flushTimer: null,
|
|
868
|
-
};
|
|
869
|
-
local.set(sid, next);
|
|
870
|
-
this.sessions.add(next);
|
|
871
|
-
send(ws, { t: 'ready', sid, pid: null, kind: 'ssh', target });
|
|
872
|
-
this.attachOutput(next);
|
|
873
|
-
this.watchDone(next, local);
|
|
874
1108
|
}
|
|
875
1109
|
else if (msg.t === 'input') {
|
|
876
1110
|
const resolved = this.resolveSid(ws, msg, local);
|
|
877
1111
|
if (resolved === undefined || 'unknown' in resolved)
|
|
878
1112
|
return;
|
|
879
1113
|
const session = local.get(resolved.sid);
|
|
880
|
-
if (session !== undefined)
|
|
1114
|
+
if (session !== undefined && !session.closed)
|
|
881
1115
|
await session.handle.write(String(msg.d ?? ''));
|
|
882
1116
|
}
|
|
883
1117
|
else if (msg.t === 'resize') {
|
|
@@ -897,6 +1131,18 @@ class TtyServer {
|
|
|
897
1131
|
}
|
|
898
1132
|
}
|
|
899
1133
|
}
|
|
1134
|
+
else if (msg.t === 'refresh') {
|
|
1135
|
+
// 强制 tmux 重画(0.10.1):客户端 reset 清掉残 scrollback 后请宿主
|
|
1136
|
+
// refresh-client 重画现场——不碰尺寸,规避隐藏标签下 proposeDimensions
|
|
1137
|
+
// 返回垃圾尺寸把 pane 压扁的隐患;非 tmux 会话为无害 no-op
|
|
1138
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
1139
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
1140
|
+
return;
|
|
1141
|
+
const session = local.get(resolved.sid);
|
|
1142
|
+
if (session !== undefined && !session.closed && session.tmuxName !== null) {
|
|
1143
|
+
void session.handle.tmuxRefresh?.();
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
900
1146
|
else if (msg.t === 'kill') {
|
|
901
1147
|
const resolved = this.resolveSid(ws, msg, local);
|
|
902
1148
|
if (resolved === undefined)
|
|
@@ -918,7 +1164,12 @@ class TtyServer {
|
|
|
918
1164
|
this.killSessionNow(session);
|
|
919
1165
|
}
|
|
920
1166
|
else if (msg.t === 'sessions') {
|
|
921
|
-
|
|
1167
|
+
// tmux 字段(0.10.1):本机 socket 现存持久会话 + SSH 持久会话名(远程
|
|
1168
|
+
// tmux 托管、本机清单看不到,从 settings 留存读取)——客户端用它确认
|
|
1169
|
+
// localStorage 里的持久标签规格是否仍可恢复(新窗口/新浏览器)
|
|
1170
|
+
const localTmux = await listTmuxSessions();
|
|
1171
|
+
const tmuxSessions = [...new Set([...localTmux, ...this.options.persistSessions])];
|
|
1172
|
+
send(ws, { t: 'sessions', list: this.sessions.listForAttach(), tmux: tmuxSessions });
|
|
922
1173
|
}
|
|
923
1174
|
else if (msg.t === 'attach') {
|
|
924
1175
|
const raw = msg.sid;
|
|
@@ -931,14 +1182,16 @@ class TtyServer {
|
|
|
931
1182
|
send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
|
|
932
1183
|
return;
|
|
933
1184
|
}
|
|
934
|
-
|
|
1185
|
+
// 跨连接共享(0.10.1):tmux 持久会话允许多窗口同时绑定(单 PTY 扇出);
|
|
1186
|
+
// 非 tmux 会话仍独占(两个视图交错输入无意义)
|
|
1187
|
+
if (session.clients.size > 0 && session.tmuxName === null) {
|
|
935
1188
|
send(ws, { t: 'error', sid: raw, m: '会话已连接到其它窗口' });
|
|
936
1189
|
return;
|
|
937
1190
|
}
|
|
938
|
-
//
|
|
939
|
-
session.
|
|
1191
|
+
// 重新绑定到本连接(key = 本连接侧的 sid):解孤儿态,恢复被背压暂停的输出流
|
|
1192
|
+
session.clients.set(raw, ws);
|
|
940
1193
|
session.orphanedAt = null;
|
|
941
|
-
local.set(
|
|
1194
|
+
local.set(raw, session);
|
|
942
1195
|
if (session.paused) {
|
|
943
1196
|
session.paused = false;
|
|
944
1197
|
try {
|
|
@@ -948,10 +1201,17 @@ class TtyServer {
|
|
|
948
1201
|
/* 已退出 */
|
|
949
1202
|
}
|
|
950
1203
|
}
|
|
951
|
-
send(ws, { t: 'ready', sid:
|
|
952
|
-
// 断线期间的输出经 256KB
|
|
953
|
-
|
|
954
|
-
|
|
1204
|
+
send(ws, { t: 'ready', sid: raw, pid: session.handle.pid, kind: session.kind, target: session.target !== '' ? session.target : undefined, reattached: true, ...(session.tmuxName !== null ? { persist: true } : {}) });
|
|
1205
|
+
// 断线期间的输出经 256KB 环形缓冲回放(缓冲为空则跳过)。
|
|
1206
|
+
// tmux 背书会话例外:现场由 tmux 负责重画——回放会把缓冲里的可见屏
|
|
1207
|
+
// 先写进全新 xterm(制造屏外幽灵滚动历史 → 莫名滚动条),随后 tmux
|
|
1208
|
+
// 整屏重画再画一遍(内容重影);故跳过回放,强制 tmux 重画一次
|
|
1209
|
+
if (session.tmuxName !== null) {
|
|
1210
|
+
void session.handle.tmuxRefresh?.();
|
|
1211
|
+
}
|
|
1212
|
+
else if (session.buffer !== '') {
|
|
1213
|
+
send(ws, { t: 'data', sid: raw, d: session.buffer });
|
|
1214
|
+
}
|
|
955
1215
|
}
|
|
956
1216
|
}
|
|
957
1217
|
catch (error) {
|
|
@@ -969,6 +1229,8 @@ class TtyServer {
|
|
|
969
1229
|
session.closed = true;
|
|
970
1230
|
local.delete(session.id);
|
|
971
1231
|
this.sessions.remove(session.id);
|
|
1232
|
+
if (session.kind === 'ssh' && session.tmuxName !== null)
|
|
1233
|
+
this.trackPersist(session.tmuxName, false);
|
|
972
1234
|
try {
|
|
973
1235
|
session.screen?.dispose();
|
|
974
1236
|
}
|
|
@@ -976,7 +1238,11 @@ class TtyServer {
|
|
|
976
1238
|
/* 已释放 */
|
|
977
1239
|
}
|
|
978
1240
|
this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
|
|
979
|
-
|
|
1241
|
+
// exit 广播到所有绑定连接(跨窗口共享),各客户端按自己的 sid 收址
|
|
1242
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1243
|
+
send(clientWs, { t: 'exit', sid: clientSid, code: outcome.exitCode, signal: outcome.signal });
|
|
1244
|
+
}
|
|
1245
|
+
session.clients.clear();
|
|
980
1246
|
}).catch(() => { });
|
|
981
1247
|
}
|
|
982
1248
|
/** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
|
|
@@ -984,17 +1250,25 @@ class TtyServer {
|
|
|
984
1250
|
const output = session.handle.output;
|
|
985
1251
|
const flush = () => {
|
|
986
1252
|
session.flushTimer = null;
|
|
987
|
-
const ws = session.ws;
|
|
988
1253
|
const pending = session.pendingOutput;
|
|
989
|
-
if (session.closed ||
|
|
1254
|
+
if (session.closed || session.clients.size === 0 || pending === '')
|
|
990
1255
|
return;
|
|
991
1256
|
session.pendingOutput = '';
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1257
|
+
let maxBuffered = 0;
|
|
1258
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1259
|
+
try {
|
|
1260
|
+
clientWs.send(JSON.stringify({ t: 'data', sid: clientSid, d: pending }), () => {
|
|
1261
|
+
if (session.paused && clientWs.bufferedAmount < BACKPRESSURE_LOW && output.readableFlowing === false) {
|
|
1262
|
+
output.resume();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
maxBuffered = Math.max(maxBuffered, clientWs.bufferedAmount);
|
|
995
1266
|
}
|
|
996
|
-
|
|
997
|
-
|
|
1267
|
+
catch {
|
|
1268
|
+
/* 客户端已断开 */
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (!session.paused && maxBuffered > BACKPRESSURE_HIGH) {
|
|
998
1272
|
session.paused = true;
|
|
999
1273
|
output.pause();
|
|
1000
1274
|
}
|
|
@@ -1013,8 +1287,7 @@ class TtyServer {
|
|
|
1013
1287
|
catch {
|
|
1014
1288
|
/* 虚拟屏异常不阻断输出链路 */
|
|
1015
1289
|
}
|
|
1016
|
-
|
|
1017
|
-
if (ws === null)
|
|
1290
|
+
if (session.clients.size === 0)
|
|
1018
1291
|
return; // 孤儿会话:仅积累缓冲,等待重连 attach 回放
|
|
1019
1292
|
// data 帧合并:窗口内攒批,超阈值立即冲刷;exit/kill 前会强制 flush 保序
|
|
1020
1293
|
session.pendingOutput += text;
|
|
@@ -1039,12 +1312,13 @@ class TtyServer {
|
|
|
1039
1312
|
clearTimeout(session.flushTimer);
|
|
1040
1313
|
session.flushTimer = null;
|
|
1041
1314
|
}
|
|
1042
|
-
const ws = session.ws;
|
|
1043
1315
|
const pending = session.pendingOutput;
|
|
1044
1316
|
session.pendingOutput = '';
|
|
1045
|
-
if (session.closed ||
|
|
1317
|
+
if (session.closed || session.clients.size === 0 || pending === '')
|
|
1046
1318
|
return;
|
|
1047
|
-
|
|
1319
|
+
for (const [clientSid, clientWs] of session.clients) {
|
|
1320
|
+
send(clientWs, { t: 'data', sid: clientSid, d: pending });
|
|
1321
|
+
}
|
|
1048
1322
|
}
|
|
1049
1323
|
close() {
|
|
1050
1324
|
for (const client of this.wss.clients) {
|
|
@@ -1280,8 +1554,11 @@ const plugin = definePlugin({
|
|
|
1280
1554
|
hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
|
|
1281
1555
|
shellIntegration: config?.shellIntegration !== false,
|
|
1282
1556
|
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
1557
|
+
persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
|
|
1558
|
+
endOnPageClose: config?.endOnPageClose === true,
|
|
1559
|
+
persistSessions: sanitizePersistSessions(config?.persistSessions) ?? [],
|
|
1283
1560
|
});
|
|
1284
|
-
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS);
|
|
1561
|
+
const sessions = new SessionManager(config?.maxSessions ?? DEFAULT_MAX_SESSIONS, () => live.endOnPageClose);
|
|
1285
1562
|
/** TOFU 指纹记录持久化:写入 settings 命名空间(合并语义),失败不影响连接。 */
|
|
1286
1563
|
const persistHostKeys = (records) => {
|
|
1287
1564
|
const scope = settingsScope;
|
|
@@ -1292,11 +1569,30 @@ const plugin = definePlugin({
|
|
|
1292
1569
|
});
|
|
1293
1570
|
};
|
|
1294
1571
|
const hostKeyStore = new HostKeyStore(live, persistHostKeys);
|
|
1572
|
+
/** SSH 持久会话名留存(远程 tmux 本机 socket 看不到;新窗口恢复确认的数据源)。 */
|
|
1573
|
+
const persistPersistSessions = () => {
|
|
1574
|
+
const scope = settingsScope;
|
|
1575
|
+
if (scope === undefined)
|
|
1576
|
+
return;
|
|
1577
|
+
void Promise.resolve(scope.update({ persistSessions: live.persistSessions.map((name) => ({ tmuxName: name })) })).catch((error) => {
|
|
1578
|
+
console.warn('[dsh-tty] 持久会话名留存失败: ' + (error instanceof Error ? error.message : String(error)));
|
|
1579
|
+
});
|
|
1580
|
+
};
|
|
1581
|
+
/** 记录/移除一个 SSH 持久会话名并落盘(幂等)。 */
|
|
1582
|
+
const trackPersistSession = (tmuxName, present) => {
|
|
1583
|
+
const next = present
|
|
1584
|
+
? (live.persistSessions.includes(tmuxName) ? live.persistSessions : [...live.persistSessions, tmuxName])
|
|
1585
|
+
: live.persistSessions.filter((name) => name !== tmuxName);
|
|
1586
|
+
if (next.join('|') === live.persistSessions.join('|'))
|
|
1587
|
+
return;
|
|
1588
|
+
live.persistSessions = next;
|
|
1589
|
+
persistPersistSessions();
|
|
1590
|
+
};
|
|
1295
1591
|
const tunnelManager = new TunnelManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore, (bookName) => live.findSshHost(bookName));
|
|
1296
1592
|
// SFTP 文件传输:懒连接池 + TOFU 同源(见 src/sftp.ts);spec 由各请求携带
|
|
1297
1593
|
// (连接簿名或内联字段),连接簿凭证热改后天然生效
|
|
1298
1594
|
const sftpManager = new SftpManager({ info: (m) => ctx.logger.info(m), warn: (m) => ctx.logger.warn(m) }, hostKeyStore);
|
|
1299
|
-
const server = new TtyServer(ctx, sessions, live, hostKeyStore);
|
|
1595
|
+
const server = new TtyServer(ctx, sessions, live, hostKeyStore, trackPersistSession);
|
|
1300
1596
|
const stateRef = { enabled: true, announceToAgent: config?.announceToAgent !== false, toolsRegistered: false, sftpStyle: config?.sftpStyle === 'dual' ? 'dual' : 'dialog' };
|
|
1301
1597
|
let settingsScope;
|
|
1302
1598
|
const snapshot = () => ({
|
|
@@ -1313,6 +1609,8 @@ const plugin = definePlugin({
|
|
|
1313
1609
|
tunnels: live.tunnels,
|
|
1314
1610
|
shellIntegration: live.shellIntegration,
|
|
1315
1611
|
sftpStyle: stateRef.sftpStyle,
|
|
1612
|
+
persistence: live.persistence,
|
|
1613
|
+
endOnPageClose: live.endOnPageClose,
|
|
1316
1614
|
toolsRegistered: stateRef.toolsRegistered,
|
|
1317
1615
|
});
|
|
1318
1616
|
/** 规范化并应用一份配置补丁(settings/updated 事件与 HTTP POST 共用;幂等)。 */
|
|
@@ -1327,6 +1625,9 @@ const plugin = definePlugin({
|
|
|
1327
1625
|
hostKeys: sanitizeHostKeys(section.hostKeys),
|
|
1328
1626
|
shellIntegration: typeof section.shellIntegration === 'boolean' ? section.shellIntegration : undefined,
|
|
1329
1627
|
tunnels: sanitizeTunnels(section.tunnels),
|
|
1628
|
+
persistence: section.persistence === 'tmux' || section.persistence === 'off' ? section.persistence : undefined,
|
|
1629
|
+
endOnPageClose: typeof section.endOnPageClose === 'boolean' ? section.endOnPageClose : undefined,
|
|
1630
|
+
persistSessions: sanitizePersistSessions(section.persistSessions),
|
|
1330
1631
|
});
|
|
1331
1632
|
// 隧道按最新规格对齐(幂等;sshHosts 变更也会触发,让重连取到新凭证)
|
|
1332
1633
|
tunnelManager.reconcile(live.tunnels);
|
|
@@ -1344,7 +1645,7 @@ const plugin = definePlugin({
|
|
|
1344
1645
|
/** 校验 HTTP POST 的配置体;返回规范化补丁或错误信息。 */
|
|
1345
1646
|
const normalizePatch = (input) => {
|
|
1346
1647
|
const patch = {};
|
|
1347
|
-
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle']);
|
|
1648
|
+
const known = new Set(['enabled', 'announceToAgent', 'maxSessions', 'shell', 'term', 'colorTerm', 'cwd', 'reconnectGraceSec', 'sshHosts', 'hostKeys', 'tunnels', 'shellIntegration', 'sftpStyle', 'persistence', 'endOnPageClose']);
|
|
1348
1649
|
for (const key of Object.keys(input)) {
|
|
1349
1650
|
if (!known.has(key))
|
|
1350
1651
|
return { error: '未知配置项: ' + key };
|
|
@@ -1381,6 +1682,16 @@ const plugin = definePlugin({
|
|
|
1381
1682
|
return { error: 'sftpStyle 必须是 dialog 或 dual' };
|
|
1382
1683
|
patch.sftpStyle = input.sftpStyle;
|
|
1383
1684
|
}
|
|
1685
|
+
if (input.persistence !== undefined) {
|
|
1686
|
+
if (input.persistence !== 'off' && input.persistence !== 'tmux')
|
|
1687
|
+
return { error: 'persistence 必须是 off 或 tmux' };
|
|
1688
|
+
patch.persistence = input.persistence;
|
|
1689
|
+
}
|
|
1690
|
+
if (input.endOnPageClose !== undefined) {
|
|
1691
|
+
if (typeof input.endOnPageClose !== 'boolean')
|
|
1692
|
+
return { error: 'endOnPageClose 必须是布尔值' };
|
|
1693
|
+
patch.endOnPageClose = input.endOnPageClose;
|
|
1694
|
+
}
|
|
1384
1695
|
for (const key of ['shell', 'term', 'colorTerm']) {
|
|
1385
1696
|
if (input[key] === undefined)
|
|
1386
1697
|
continue;
|
|
@@ -1834,6 +2145,13 @@ const plugin = definePlugin({
|
|
|
1834
2145
|
startup.shellIntegration = false;
|
|
1835
2146
|
if (stored.sftpStyle === 'dual')
|
|
1836
2147
|
startup.sftpStyle = 'dual';
|
|
2148
|
+
if (stored.persistence === 'tmux')
|
|
2149
|
+
startup.persistence = 'tmux';
|
|
2150
|
+
if (stored.endOnPageClose === true)
|
|
2151
|
+
startup.endOnPageClose = true;
|
|
2152
|
+
const storedPersistSessions = sanitizePersistSessions(stored.persistSessions);
|
|
2153
|
+
if (storedPersistSessions !== undefined && storedPersistSessions.length > 0)
|
|
2154
|
+
startup.persistSessions = storedPersistSessions;
|
|
1837
2155
|
const storedHosts = sanitizeSshHosts(stored.sshHosts);
|
|
1838
2156
|
if (storedHosts !== undefined && storedHosts.length > 0)
|
|
1839
2157
|
startup.sshHosts = storedHosts;
|
|
@@ -1893,6 +2211,7 @@ const plugin = definePlugin({
|
|
|
1893
2211
|
cwd: { type: 'string', required: true },
|
|
1894
2212
|
startedAt: { type: 'number', required: true },
|
|
1895
2213
|
lastOutputAt: { type: 'number', required: true },
|
|
2214
|
+
persist: { type: 'boolean' },
|
|
1896
2215
|
},
|
|
1897
2216
|
},
|
|
1898
2217
|
},
|
|
@@ -1904,7 +2223,8 @@ const plugin = definePlugin({
|
|
|
1904
2223
|
? '当前没有活跃的终端面板会话(请引导用户先打开终端面板,或用户尚未打开)'
|
|
1905
2224
|
: '终端面板会话:' + sessions.map((s) => {
|
|
1906
2225
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
1907
|
-
|
|
2226
|
+
const persist = s.persist === true ? ' [tmux 持久]' : '';
|
|
2227
|
+
return `\n- sid=${s.sid} [${s.kind}]${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
|
|
1908
2228
|
}).join('');
|
|
1909
2229
|
return [{ type: 'text', text }];
|
|
1910
2230
|
},
|
|
@@ -2524,7 +2844,7 @@ const plugin = definePlugin({
|
|
|
2524
2844
|
return '当前没有活跃的终端面板会话(可引导用户打开「终端」面板,或用 spawn 类工作流替代)。';
|
|
2525
2845
|
return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,sid 如下):\n' + list.map((s) => {
|
|
2526
2846
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
2527
|
-
return `- sid=${s.sid} [${s.kind}] ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
2847
|
+
return `- sid=${s.sid} [${s.kind}]${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
2528
2848
|
}).join('\n');
|
|
2529
2849
|
},
|
|
2530
2850
|
});
|