@hyzyn/dsh-tty 0.19.3 → 0.20.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/README.en.md +47 -7
- package/README.md +38 -8
- package/client.js +155 -38
- package/lib/index.d.ts +192 -1
- package/lib/index.js +664 -102
- package/lib/index.js.map +1 -1
- package/lib/tunnels.js +7 -0
- package/lib/tunnels.js.map +1 -1
- package/package.json +1 -1
- package/scripts/integration.mjs +171 -14
- package/scripts/preview/harness.js +295 -0
- package/scripts/preview/mock-host.js +6 -0
- package/scripts/preview.mjs +3 -0
- package/scripts/screen-crash-repro.mjs +62 -0
package/lib/index.d.ts
CHANGED
|
@@ -74,6 +74,7 @@ declare const HeadlessTerminal: typeof xtermHeadless.Terminal;
|
|
|
74
74
|
type HeadlessTerminal = InstanceType<typeof HeadlessTerminal>;
|
|
75
75
|
import type { HostKeyRecord, SshHostEntry, TermHandle } from './ssh.js';
|
|
76
76
|
import type { TunnelSpec } from './tunnels.js';
|
|
77
|
+
import type { StatsFrame } from './stats.js';
|
|
77
78
|
export type { HostKeyRecord } from './ssh.js';
|
|
78
79
|
export interface Config {
|
|
79
80
|
/** 关闭整个插件。默认开。 */
|
|
@@ -160,6 +161,16 @@ interface TtySession {
|
|
|
160
161
|
}>;
|
|
161
162
|
closed: boolean;
|
|
162
163
|
paused: boolean;
|
|
164
|
+
/**
|
|
165
|
+
* 会话归属(agent 侧 tty_open):'user' = 面板标签开的、有客户端绑定;
|
|
166
|
+
* 'agent' = agent 用 tty_open 开的,**可能长时间无客户端**。
|
|
167
|
+
*
|
|
168
|
+
* 为什么必须区分:孤儿回收器的判据是「无客户端绑定」(`orphanedAt !== null`),
|
|
169
|
+
* 而 agent 开的会话从出生起就没有客户端——不豁免的话会被回收器当孤儿秒收,
|
|
170
|
+
* 长驻任务(dev server / build)刚起来就没了。豁免之后关闭入口只有两个:
|
|
171
|
+
* agent 的 `tty_close`,或用户在面板里接管后照常关标签。
|
|
172
|
+
*/
|
|
173
|
+
owner: 'user' | 'agent';
|
|
163
174
|
/** exit 帧只发一次(kill 主动关闭与 shell 自然退出共用同一回调)。 */
|
|
164
175
|
exitSent?: boolean;
|
|
165
176
|
/** agent 工具展示用的元数据。 */
|
|
@@ -177,6 +188,10 @@ interface TtySession {
|
|
|
177
188
|
decoder: StringDecoder;
|
|
178
189
|
/** 虚拟屏(xterm-headless):tty_screen 的数据源;创建失败为 null。 */
|
|
179
190
|
screen: HeadlessTerminal | null;
|
|
191
|
+
/** 虚拟屏心跳(D57 停摆检测):在途批次 + 看门狗。 */
|
|
192
|
+
screenHeartbeat: ScreenHeartbeat;
|
|
193
|
+
/** 虚拟屏被退役的原因(停摆 / 写队列满);null = 正常。tty_screen 据此如实报错。 */
|
|
194
|
+
screenDownReason: string | null;
|
|
180
195
|
/** 转入孤儿状态的时间戳;null 表示已连接(客户端在线)。 */
|
|
181
196
|
orphanedAt: number | null;
|
|
182
197
|
/** shell 集成状态(OSC 133/7 解析;本地与 SSH 会话都喂)。 */
|
|
@@ -192,6 +207,8 @@ interface TtySession {
|
|
|
192
207
|
* 驱动)。空集合 = 该会话不需要采集,采集器必须停(防定时器/远程 channel 泄漏)。
|
|
193
208
|
*/
|
|
194
209
|
statsSubs: Set<string>;
|
|
210
|
+
/** 最近一帧服务器状态指标(tty_stats 的一条数据源;未采过为 null)。 */
|
|
211
|
+
lastStats: StatsFrame | null;
|
|
195
212
|
/** 采集器句柄;null = 未启动(懒启动:首个 statsOn 才起)。 */
|
|
196
213
|
stats: StatsCollector | null;
|
|
197
214
|
/** 采集已永久失败(远端无 /proc、exec 被拒、连接断开):不再重启,前端隐藏状态条。 */
|
|
@@ -306,6 +323,104 @@ declare class HostKeyStore {
|
|
|
306
323
|
/** 记录指纹:同 host:port 已有记录则并入集合(一机多把钥匙),否则新建。 */
|
|
307
324
|
record(host: string, port: number, fingerprint: string): void;
|
|
308
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* 虚拟屏的 scrollback 余量(D57)。
|
|
328
|
+
*
|
|
329
|
+
* **不能是 0。** xterm 的 `Buffer` 在 `scrollback: 0` 时把 `lines.maxLength` 压成
|
|
330
|
+
* `rows`,但 normal buffer 的 `_hasScrollback` 仍是 true(reflow 照常开启):输出与
|
|
331
|
+
* resize(列宽变化触发 reflow)交错时,`lines` 会短于 `ybase + y`,于是
|
|
332
|
+
* `lineFeed()` 里 `lines.get(ybase + y).isWrapped = false` 命中 `undefined` →
|
|
333
|
+
* 未捕获 `TypeError: Cannot set properties of undefined (setting 'isWrapped')`。
|
|
334
|
+
*
|
|
335
|
+
* 该异常抛在 `WriteBuffer._innerWrite` 的 `setTimeout` 回调里,写入路径的同步
|
|
336
|
+
* try/catch 结构性拦不住,会直接打死整个宿主进程(线上 `last-failure-web.log`
|
|
337
|
+
* 的堆栈即此)。
|
|
338
|
+
*
|
|
339
|
+
* 关键在 `lines.maxLength`(= rows + scrollback):`BufferService.scroll` 只在「没满」时
|
|
340
|
+
* 才 `lines.push` + `ybase++`(成对)。`scrollback: 0` 把 maxLength 钉死成 rows,
|
|
341
|
+
* 一旦有别的路径把 `ybase` 顶上去(resize 收缩 / reflow),`lines` 长度就再也追不上,
|
|
342
|
+
* `ybase + y + 1` 越界只是时间问题。留 1 行余量(maxLength = rows + 1)即维持住成对增长:
|
|
343
|
+
* 同一最小序列 `scrollback: 0` 崩 5/5,`scrollback: 1` 崩 0/5;700 块随机屏压测里
|
|
344
|
+
* `ybase` 涨到 16 也没再出现越界(见 test/screen-crash.test.ts)。
|
|
345
|
+
*
|
|
346
|
+
* 余量不影响 `tty_screen` 读数——它走 `buffer.getLine(row)`(内部 `ybase + row`,
|
|
347
|
+
* 即视口),多出来的行只在回滚区,不进读数。
|
|
348
|
+
*/
|
|
349
|
+
export declare const SCREEN_SCROLLBACK = 1;
|
|
350
|
+
/**
|
|
351
|
+
* 建一块虚拟屏(`tty_screen` 的数据源);失败降级为 null。
|
|
352
|
+
*
|
|
353
|
+
* 导出仅供单测(test/screen-crash.test.ts)钉住构造参数——生产路径是
|
|
354
|
+
* `SessionManager.createScreen`,它必须与这里同源(就一行委托)。
|
|
355
|
+
*/
|
|
356
|
+
export declare function createHeadlessScreen(cols: number, rows: number): HeadlessTerminal | null;
|
|
357
|
+
/** 读累计吞掉的虚拟屏异常数。 */
|
|
358
|
+
export declare function xtermScreenCrashCount(): number;
|
|
359
|
+
/** 判定未捕获异常是否来自虚拟屏(xterm-headless)。导出仅供单测。 */
|
|
360
|
+
export declare function isXtermScreenCrash(err: unknown): boolean;
|
|
361
|
+
/**
|
|
362
|
+
* 记账并吞掉一个虚拟屏异常;返回 true 表示已吞(非虚拟屏异常返回 false,交回调用方)。
|
|
363
|
+
* 导出仅供单测。
|
|
364
|
+
*/
|
|
365
|
+
export declare function swallowXtermScreenCrash(err: unknown): boolean;
|
|
366
|
+
/**
|
|
367
|
+
* 注册进程级虚拟屏异常兜底(D57):把来自 xterm-headless 的未捕获异常 / 未处理 rejection
|
|
368
|
+
* 吞掉并记账,让插件自己的 bug 不再拖垮整个 harness。幂等 + 引用计数,返回解绑函数。
|
|
369
|
+
*
|
|
370
|
+
* 覆盖两个入口:
|
|
371
|
+
* - `uncaughtException`:同步路径(`_innerWrite` 的定时器回调里抛出,见上);
|
|
372
|
+
* - `unhandledRejection`:xterm 的异步 handler(DCS/OSC)rejection 走这里,宿主实测
|
|
373
|
+
* **0 处**监听,Node 15+ 下未处理 rejection 直接杀进程。
|
|
374
|
+
*
|
|
375
|
+
* 三条边界(刻意如此,不是随手 `process.on`):
|
|
376
|
+
* 1. **只吞虚拟屏异常**——`isXtermScreenCrash` 按堆栈判定;其余异常照旧。
|
|
377
|
+
* 2. **其余异常只在「我们是唯一的监听者」时抛回**:没有本兜底时未捕获异常会让宿主退出,
|
|
378
|
+
* 抛回保住这个语义;已经有别的监听者(宿主/其它插件)时保持沉默,由它们决定——
|
|
379
|
+
* 此时抛回反而会抢在别人前面把进程杀掉。
|
|
380
|
+
* 3. `unhandledRejection` 的「抛回」还有一层必要性:**只要挂了监听器,Node 就不再走
|
|
381
|
+
* 默认的致命处理**,所以非虚拟屏的 rejection 必须由我们抛出来还原默认行为
|
|
382
|
+
* (已实测:抛回后进程照旧 exit 1)。
|
|
383
|
+
*/
|
|
384
|
+
export declare function installXtermScreenCrashGuard(): () => void;
|
|
385
|
+
/**
|
|
386
|
+
* 停摆判定窗口:写出去的数据超过这么久还没解析完,就认定那块屏的解析器已停摆。
|
|
387
|
+
* 正常屏的解析是毫秒级(回调随 `_innerWrite` 逐批回来),5s 不会误伤。
|
|
388
|
+
*/
|
|
389
|
+
export declare const SCREEN_STALL_MS = 5000;
|
|
390
|
+
/** 退役原因①:写队列超限 / 尺寸非法导致的同步抛出。 */
|
|
391
|
+
export declare const SCREEN_DOWN_WRITE_REJECTED = "\u5199\u5165\u88AB\u62D2\uFF08\u5199\u961F\u5217\u8D85\u9650\u6216\u5C3A\u5BF8\u975E\u6CD5\uFF09";
|
|
392
|
+
/** 退役原因②:解析器停摆(超时窗口内没有任何一批数据被解析完)。 */
|
|
393
|
+
export declare const SCREEN_DOWN_STALLED = "\u89E3\u6790\u505C\u6446\uFF08xterm \u5728\u8D85\u65F6\u7A97\u53E3\u5185\u672A\u56DE\u8C03\uFF09";
|
|
394
|
+
/** 虚拟屏心跳:在途批次 + 看门狗(D57)。 */
|
|
395
|
+
export interface ScreenHeartbeat {
|
|
396
|
+
/** 已写出、尚未被 xterm 解析完的批次(`write(data, cb)` 的回调未回来即 >0)。 */
|
|
397
|
+
inflight: number;
|
|
398
|
+
/** 看门狗;null = 当前没有挂着的窗口。 */
|
|
399
|
+
watchdog: NodeJS.Timeout | null;
|
|
400
|
+
/** 最近一次解析完成的时间戳(0 = 从未)。看门狗靠它区分「解析在途」与「真停摆」。 */
|
|
401
|
+
lastParseAt: number;
|
|
402
|
+
}
|
|
403
|
+
/** 建一份空心跳。 */
|
|
404
|
+
export declare function newScreenHeartbeat(): ScreenHeartbeat;
|
|
405
|
+
/** 摘掉看门狗(会话结束 / 屏退役时调用,避免定时器在会话死后误报)。 */
|
|
406
|
+
export declare function clearScreenWatchdog(heartbeat: ScreenHeartbeat): void;
|
|
407
|
+
/**
|
|
408
|
+
* 写一帧到虚拟屏,并维护停摆看门狗(D57)。
|
|
409
|
+
*
|
|
410
|
+
* **为什么需要心跳**:xterm 的解析在 `WriteBuffer._innerWrite` 的 setTimeout 回调里跑,
|
|
411
|
+
* 异常被进程级兜底吞掉之后,那块屏的解析器**永久停摆**——出错的那批数据留在写队列里、
|
|
412
|
+
* `_bufferOffset` 不前进,而 `write()` 只在队列**空**时才重新调度解析。后果:`tty_screen`
|
|
413
|
+
* 一直返回**冻结的旧画面**(agent 会据此行事),写队列还会一路堆到 5e7 字符上限。
|
|
414
|
+
* 心跳把这种屏识别出来退役,`tty_screen` 改为如实报「虚拟屏不可用」。
|
|
415
|
+
*
|
|
416
|
+
* 信号用 `write(data, cb)` 的回调(xterm 解析完这批数据才回调):停摆时回调永远不来 →
|
|
417
|
+
* `inflight` 不归零 → 看门狗判定。**不能用 `onWriteParsed` 事件**——它在 5.5.0 不是
|
|
418
|
+
* 公开 API(`Terminal` 只暴露 onBell/onBinary/onCursorMove/onData/onLineFeed/onResize/
|
|
419
|
+
* onScroll/onTitleChange)。
|
|
420
|
+
*/
|
|
421
|
+
export declare function writeToScreen(screen: {
|
|
422
|
+
write(data: string, callback?: () => void): void;
|
|
423
|
+
}, heartbeat: ScreenHeartbeat, text: string, onStall: (reason: string) => void, stallMs?: number): void;
|
|
309
424
|
/** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
|
|
310
425
|
export declare class SessionManager {
|
|
311
426
|
private readonly sessions;
|
|
@@ -333,6 +448,7 @@ export declare class SessionManager {
|
|
|
333
448
|
startedAt: number;
|
|
334
449
|
lastOutputAt: number;
|
|
335
450
|
persist?: true;
|
|
451
|
+
owner: 'user' | 'agent';
|
|
336
452
|
}>;
|
|
337
453
|
/** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
|
|
338
454
|
listForAttach(): Array<{
|
|
@@ -344,6 +460,7 @@ export declare class SessionManager {
|
|
|
344
460
|
startedAt: number;
|
|
345
461
|
lastOutputAt: number;
|
|
346
462
|
persist?: true;
|
|
463
|
+
owner: 'user' | 'agent';
|
|
347
464
|
attachable: boolean;
|
|
348
465
|
}>;
|
|
349
466
|
/** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
|
|
@@ -359,6 +476,10 @@ export declare class SessionManager {
|
|
|
359
476
|
* 回收孤儿会话(回收器定时调用):超过保活期的回收。graceMs<=0 时立即回收
|
|
360
477
|
* 全部孤儿——孤儿只在「断开瞬间 grace>0」时产生,热改 grace 为 0 不能只管
|
|
361
478
|
* 以后:已存在的孤儿会永久占 PTY 与名额,满额后新标签一直报「会话数已达上限」。
|
|
479
|
+
*
|
|
480
|
+
* agent 开的会话(owner:'agent')不走这条:它从出生起就没有客户端,判据
|
|
481
|
+
* 「orphanedAt !== null」对它要么永不成立(不回收)要么被误当孤儿(一开就收)。
|
|
482
|
+
* 它的关闭入口是 agent 的 tty_close 或用户在面板里接管后关标签。
|
|
362
483
|
*/
|
|
363
484
|
reapOrphans(graceMs: number): Promise<void>;
|
|
364
485
|
disposeAll(): Promise<void>;
|
|
@@ -374,6 +495,8 @@ export declare class TtyServer {
|
|
|
374
495
|
private readonly wss;
|
|
375
496
|
/** 在途的持久会话创建(tmuxName → 创建 promise):dsh 重启后多页面并发恢复时收敛竞态。 */
|
|
376
497
|
private readonly pendingTmux;
|
|
498
|
+
/** 已接线的面板连接(sessions 帧广播用;比 wss.clients 更贴合「面板」语义,单测也可驱动)。 */
|
|
499
|
+
private readonly panels;
|
|
377
500
|
/** WS 闸门(插件禁用时关闭):拒绝新升级 + 断开存量连接。 */
|
|
378
501
|
private wsGateOpen;
|
|
379
502
|
/** 服务器状态条总开关(配置热生效;关闭时停掉全部采集,重开按订阅恢复)。 */
|
|
@@ -425,8 +548,67 @@ export declare class TtyServer {
|
|
|
425
548
|
private resolveSid;
|
|
426
549
|
/** 把一个客户端连接重绑定到既有会话(跨窗口共享 / 并发恢复收敛共用)。 */
|
|
427
550
|
private rebindClient;
|
|
551
|
+
/**
|
|
552
|
+
* agent 开一个本地终端(tty_open 的实现)。
|
|
553
|
+
*
|
|
554
|
+
* 设计前提(与用户确认过):**开成面板里的普通会话,不做隐形会话** ——
|
|
555
|
+
* 会话照常进 `sessions` 快照、面板能看见并接管、用户随时可以关。理由是
|
|
556
|
+
* D06 那类「僵尸会话」正是隐形会话的产物:用户不知道机器上跑着什么。
|
|
557
|
+
*
|
|
558
|
+
* 与 `spawn` 帧的差别只有两处:没有 ws(clients 空表)、owner:'agent'
|
|
559
|
+
* (逃过孤儿回收,见 reapOrphans)。
|
|
560
|
+
*/
|
|
561
|
+
openAgentSession(input: {
|
|
562
|
+
cwd?: string;
|
|
563
|
+
command?: string | null;
|
|
564
|
+
persistName?: string | null;
|
|
565
|
+
cols?: unknown;
|
|
566
|
+
rows?: unknown;
|
|
567
|
+
}): Promise<{
|
|
568
|
+
sid: string;
|
|
569
|
+
persist: boolean;
|
|
570
|
+
}>;
|
|
571
|
+
/** agent 关掉一个会话(tty_close 的实现):只允许关 agent 自己开的,用户标签不越权。 */
|
|
572
|
+
closeAgentSession(sid: string): Promise<{
|
|
573
|
+
ok: true;
|
|
574
|
+
}>;
|
|
575
|
+
/**
|
|
576
|
+
* 把当前会话清单推给所有已连接面板(agent 开关会话后让面板即时反映)。
|
|
577
|
+
*
|
|
578
|
+
* 用自己登记的连接集合而不是 `this.wss.clients`:后者只在真实 WS 服务器
|
|
579
|
+
* 接线时才有值(单测直接调 onConnection 时为空),且语义上我们要的是
|
|
580
|
+
* 「已接线的面板连接」。
|
|
581
|
+
*/
|
|
582
|
+
private broadcastSessions;
|
|
583
|
+
/**
|
|
584
|
+
* 取一次会话所在机器的指标(tty_stats 的实现)。
|
|
585
|
+
*
|
|
586
|
+
* 按需采样、不依赖面板是否订阅状态条:本地会话直接跑本地采样器;SSH 会话在
|
|
587
|
+
* 同一连接上开一次性 exec channel 跑一帧脚本(statsExec 的常驻循环不适合
|
|
588
|
+
* 一次性取数,故用 handle.statsExec 的单帧变体——没有的话返回最近留档)。
|
|
589
|
+
* 失败不抛给 agent 的判断链:返回 available:false + 原因。
|
|
590
|
+
*/
|
|
591
|
+
sampleStats(session: TtySession): Promise<{
|
|
592
|
+
available: boolean;
|
|
593
|
+
reason?: string;
|
|
594
|
+
frame?: StatsFrame;
|
|
595
|
+
}>;
|
|
428
596
|
/** 等待同 tmuxName 的在途创建完成;返回可重绑定的会话(null = 无在途/已失败)。 */
|
|
429
597
|
private waitPendingTmux;
|
|
598
|
+
/**
|
|
599
|
+
* 创建本地会话(0.20.0 抽出,供 WS `spawn` 帧与 agent `tty_open` 共用)。
|
|
600
|
+
*
|
|
601
|
+
* 与连接无关是这次抽出的全部意义:`spawn` 帧带一个 ws(用户开的标签要立刻
|
|
602
|
+
* ready + 收输出),`tty_open` 没有 ws(agent 开的会话从出生起就没有客户端,
|
|
603
|
+
* 靠 owner:'agent' 逃过孤儿回收)。两条路径共用同一套:
|
|
604
|
+
* - tmux 持久化探测与资源准备(同 persistName 复用既有会话,名额不翻倍);
|
|
605
|
+
* - cwd 校验、spawnPlan 组装、并发在途收敛(pendingTmux);
|
|
606
|
+
* - 会话对象装配 + 输出下行挂载 + 退出收尾。
|
|
607
|
+
*
|
|
608
|
+
* 调用方负责:上限检查(canSpawn)、错误帧、ready/notice 的呈现。
|
|
609
|
+
* `client` 为 null 时创建无客户端的会话(agent 路径)。
|
|
610
|
+
*/
|
|
611
|
+
private createLocalSession;
|
|
430
612
|
/**
|
|
431
613
|
* 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
|
|
432
614
|
* 树级子进程清理(SIGTERM→grace→SIGKILL,交互式 zsh 忽略 SIGTERM 时最慢
|
|
@@ -435,8 +617,17 @@ export declare class TtyServer {
|
|
|
435
617
|
* 会话会留在 tmux server 上);2.5s 兜底 forceKill 防收尾悬挂。
|
|
436
618
|
*/
|
|
437
619
|
private killSessionNow;
|
|
438
|
-
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
620
|
+
/** 每会话一块虚拟屏(xterm-headless):tty_screen 的数据源;失败降级为 null。
|
|
621
|
+
* 构造参数在 createHeadlessScreen(D57:scrollback 不能是 0),这里只做委托。 */
|
|
439
622
|
private createScreen;
|
|
623
|
+
/**
|
|
624
|
+
* 退役一块**不可用**的虚拟屏(D57):解析停摆或写队列满时调用。
|
|
625
|
+
*
|
|
626
|
+
* 只摘虚拟屏,**不动会话**——PTY 还活着、浏览器面板照常收发(虚拟屏只是 `tty_screen`
|
|
627
|
+
* 的数据源)。退役后 `tty_screen` 会如实报「虚拟屏不可用(原因)」,而不是返回冻结的
|
|
628
|
+
* 旧画面让 agent 据此行事。
|
|
629
|
+
*/
|
|
630
|
+
private dropScreen;
|
|
440
631
|
private handleMessage;
|
|
441
632
|
/**
|
|
442
633
|
* 服务器状态条订阅(0.17.0):按「标签可见性」驱动——只有可见标签才发
|