@hyzyn/dsh-tty 0.22.0-rc.1 → 0.22.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.d.ts CHANGED
@@ -143,6 +143,43 @@ export interface SftpLimits {
143
143
  * `applyPatch` 热应用(见 @hyzyn/dsh-kit 的 settingsEntryScope)。
144
144
  */
145
145
  export declare const Config: z;
146
+ /**
147
+ * 会话退出后的**只读保留策略**(D77)。
148
+ *
149
+ * 进程没了之后把会话留在 `sessions` 表里:`tty_list` / `tty_capture` / `tty_screen`
150
+ * 照常能读到它最后那些输出(用户上报的痛点原话:「结果明明就在那里但我看不到」——
151
+ * `tty_open` 跑一条命令,跑完会话就退役,AI 一个字符都取不回来,逼得人先开
152
+ * `/bin/sh` 再往里发命令)。
153
+ *
154
+ * 取 **∞ = 保留到显式关闭**(`tty_close` / 面板关标签 / 宿主重启),不由时间淘汰:
155
+ *
156
+ * - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
157
+ * 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
158
+ * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
159
+ * 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
160
+ * 宿主 / 插件重启即清空,不会跨天累积;
161
+ * - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
162
+ * 正是想要的语义:近的才有人读。
163
+ *
164
+ * 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
165
+ * (回收器每轮都会调它)。导出仅供单测(test/host-frames.test.ts):到点退役与
166
+ * 条数上限的行为护栏。
167
+ */
168
+ export declare const EXITED_RETAIN_MS: number;
169
+ /**
170
+ * 只读保留的会话数上限(超出按最旧淘汰,见 SessionManager.capExited)。
171
+ *
172
+ * 与「并发会话上限」(maxSessions,默认 4)是两个口径:那个数**只数活着的会话**
173
+ * (retained 的不占名额,否则跑几条短命令就把面板顶成「会话数已达上限」,
174
+ * 比原缺陷更糟)。这里兜的是内存:每条 retained 约 = 256KB 环形缓冲 + 一块
175
+ * xterm-headless 虚拟屏,16 条仍在 ~20MB 量级。
176
+ *
177
+ * 为什么从 8 抬到 16(D77 补,2026-09-27 真机验收反馈):保留改成「留到显式关闭」
178
+ * 之后,上限就是唯一会**自动**挤掉结果的东西,而 agent 一口气开二十几条一次性
179
+ * 会话是常态——8 条意味着「几分钟前那份结果」被最旧淘汰挤掉,用户只看到「没了」。
180
+ * 淘汰一律 `logger.warn` 留痕(见 finishSession),否则这件事在事后完全不可查。
181
+ */
182
+ export declare const MAX_EXITED_SESSIONS = 16;
146
183
  /**
147
184
  * 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
148
185
  *
@@ -156,6 +193,18 @@ export declare const Config: z;
156
193
  * 导出仅供单测(test/host-frames.test.ts):平台参数注入,两个分支都能在 macOS/Linux 断言。
157
194
  */
158
195
  export declare function killLocalShellTerminal(terminal: unknown, platform?: NodeJS.Platform): void;
196
+ /**
197
+ * Windows 本地 PTY 的**输入归一化**(D74,2026-09-27 真机报告):conhost 把 Enter 当
198
+ * **CR**,裸 LF 只把光标下移一格、**不提交命令行**——于是 `tty_send` 按工具描述发
199
+ * `echo X\n` 时,命令停在输入行上:没有输出、没有新提示符,看起来像「发出去了但没执行」。
200
+ *
201
+ * 修法取报告建议里改动最小的一条:win32 上把行尾补成 CRLF(已有 CR 的不重复补),
202
+ * 让「照描述写 `\n`」这条主路径直接可用。非 win32 原样透传(POSIX 的 Enter 就是 LF),
203
+ * SSH 会话也不归一化(远端是什么系统、什么 shell 插件不知道,乱改会破坏 `cat` 之类的原始输入)。
204
+ *
205
+ * 导出仅供单测:平台参数注入,两个分支都能在 macOS/Linux 断言。
206
+ */
207
+ export declare function normalizePtyInput(data: string, platform?: NodeJS.Platform): string;
159
208
  /** 一次「会话 → 帧」采集器的句柄(本地 = 定时器,SSH = 远端长驻 exec channel)。 */
160
209
  interface StatsCollector {
161
210
  stop(): void;
@@ -189,6 +238,18 @@ interface TtySession {
189
238
  * agent 的 `tty_close`,或用户在面板里接管后照常关标签。
190
239
  */
191
240
  owner: 'user' | 'agent';
241
+ /**
242
+ * **只读保留态**(D77):进程已退出,但会话**还留在表里**——读侧工具照常可用,
243
+ * 写侧明确拒写,用户与 agent 都能显式关掉它(`tty_close` / 面板关标签 / TTL 到点)。
244
+ *
245
+ * 与 `closed` 是两件事:`closed` = 真退役(出表 + 释放屏,见 SessionManager.retire),
246
+ * 而「进程退出」**不再**等于退役——否则退出瞬间那些输出就再也取不回来了。
247
+ */
248
+ exited: {
249
+ code: number | null;
250
+ signal: string | null;
251
+ at: number;
252
+ } | null;
192
253
  /** exit 帧只发一次(kill 主动关闭与 shell 自然退出共用同一回调)。 */
193
254
  exitSent?: boolean;
194
255
  /** agent 工具展示用的元数据。 */
@@ -206,6 +267,13 @@ interface TtySession {
206
267
  readSeq: number;
207
268
  /** 水位线最近一次推进的时刻(D72):`lastCommand.endedAt > readMarkAt` = 这条命令的输出还没被读过。 */
208
269
  readMarkAt: number;
270
+ /**
271
+ * 最近若干条「agent 提交过的命令行」(D75,来自 `tty_send` 且带行尾的那些):
272
+ * 无 shell 集成时 PTY 会把它们**原样回显**进输出流,`tty_expect` 拿回显当命中
273
+ * 就是假阳性(命令还没执行就报 matched)。用这份清单把回显行从匹配窗口里剔掉。
274
+ * 只记 `tty_send` 的写入:面板逐键输入不做行编辑模拟(宁可少抑制,不可错抑制)。
275
+ */
276
+ recentInputs: string[];
209
277
  /** 输出环形缓冲(尾部 256KB,供 tty_capture 与断线重连回放)。 */
210
278
  buffer: string;
211
279
  /** utf8 分帧兜底:跨 chunk 的多字节序列由 StringDecoder 缓存补齐。 */
@@ -449,6 +517,26 @@ export declare function clearScreenWatchdog(heartbeat: ScreenHeartbeat): void;
449
517
  export declare function writeToScreen(screen: {
450
518
  write(data: string, callback?: () => void): void;
451
519
  }, heartbeat: ScreenHeartbeat, text: string, onStall: (reason: string) => void, stallMs?: number): void;
520
+ /** 会话的只读快照形状(tty_list 与 sessions 帧共用;D77 起含只读保留态字段)。 */
521
+ export interface SessionSnapshot {
522
+ sid: string;
523
+ pid?: number;
524
+ cwd: string;
525
+ kind: 'local' | 'ssh';
526
+ target: string;
527
+ startedAt: number;
528
+ lastOutputAt: number;
529
+ persist?: true;
530
+ owner: 'user' | 'agent';
531
+ /** 进程已退出、会话仍在只读保留期内(D77)。 */
532
+ exited?: true;
533
+ /** 退出码(拿不到时省略)。 */
534
+ exitCode?: number;
535
+ /** 退出信号(正常退出时省略)。 */
536
+ signal?: string;
537
+ /** 只读保留的剩余毫秒;**省略 = 不按时间释放**(策略为 ∞,关闭或宿主重启才清)。 */
538
+ retainMs?: number;
539
+ }
452
540
  /** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
453
541
  export declare class SessionManager {
454
542
  private readonly sessions;
@@ -460,40 +548,33 @@ export declare class SessionManager {
460
548
  /** 配置热生效时调整上限(1~16)。 */
461
549
  setLimit(maxSessions: number): void;
462
550
  get count(): number;
551
+ /** 活着的会话数(**不含**只读保留的,见 canSpawn)。 */
552
+ get liveCount(): number;
553
+ /** 只读保留的会话数(D77)。 */
554
+ get exitedCount(): number;
555
+ /**
556
+ * 名额判据**只数活着的会话**(D77):只读保留的不占名额。不这样分的话,
557
+ * 「跑几条短命令」就能把面板顶成「会话数已达上限」——用户一条会话都没开,
558
+ * 比原来那个「AI 取不到结果」的缺陷更糟。
559
+ */
463
560
  canSpawn(): boolean;
464
561
  add(session: TtySession): void;
465
562
  remove(id: string): void;
466
563
  get(id: string): TtySession | undefined;
467
- /** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist)。 */
564
+ /** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist;
565
+ * 只读保留态(D77)额外带 exited/exitCode|signal/retainMs)。 */
468
566
  private snapshotOf;
469
567
  /** agent 工具用的只读快照。 */
470
- list(): Array<{
471
- sid: string;
472
- pid?: number;
473
- cwd: string;
474
- kind: 'local' | 'ssh';
475
- target: string;
476
- startedAt: number;
477
- lastOutputAt: number;
478
- persist?: true;
479
- owner: 'user' | 'agent';
480
- }>;
568
+ list(): SessionSnapshot[];
481
569
  /** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
482
- listForAttach(): Array<{
483
- sid: string;
484
- pid?: number;
485
- cwd: string;
486
- kind: 'local' | 'ssh';
487
- target: string;
488
- startedAt: number;
489
- lastOutputAt: number;
490
- persist?: true;
491
- owner: 'user' | 'agent';
570
+ listForAttach(): Array<SessionSnapshot & {
492
571
  attachable: boolean;
493
572
  }>;
494
573
  /** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
495
574
  forEach(fn: (session: TtySession) => void): void;
496
- /** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
575
+ /** 按 tmux 持久会话名查找**活着**的会话(跨窗口共享用);不存在/已关闭/只读保留态返回 undefined。
576
+ * D77:保留态必须排除——否则「同名 persistName 的新标签」会 rebind 到一具尸体上,
577
+ * 拿到 ready 却永远没有输出。 */
497
578
  findByTmuxName(tmuxName: string): TtySession | undefined;
498
579
  /** 同步退役:移出全局表 + 释放虚拟屏(幂等,不杀进程)。 */
499
580
  retire(session: TtySession): void;
@@ -510,6 +591,24 @@ export declare class SessionManager {
510
591
  * 它的关闭入口是 agent 的 tty_close 或用户在面板里接管后关标签。
511
592
  */
512
593
  reapOrphans(graceMs: number): Promise<void>;
594
+ /**
595
+ * 只读保留到点退役(D77;回收器每轮调用):超过保留期的会话出表 + 释放屏。
596
+ *
597
+ * 默认策略是 ∞(保留到显式关闭)⇒ 本方法是 no-op,条数由 `capExited` 兜;
598
+ * 把 `EXITED_RETAIN_MS` 改成有限值它就照常工作(策略可调,通路留着)。
599
+ *
600
+ * **不 kill 进程**:这里收的全是已经退出的会话(进程早没了),`retire()` 就够;
601
+ * 真退役(显式 `tty_close` / 面板关标签)走 `killSessionNow`,那条路要处理
602
+ * tmux teardown 与 forceKill 的兜底。
603
+ */
604
+ reapExited(retainMs: number): void;
605
+ /**
606
+ * 只读保留的数量上限(超出按最旧淘汰,D77):返回被淘汰的会话,便于单测断言。
607
+ *
608
+ * 为什么必须有:`owner:'agent'` 的会话不会走孤儿回收,agent 若不显式 `tty_close`
609
+ * (它常常不会),保留态就是**永久泄漏**——屏与 256KB 缓冲一直挂着。
610
+ */
611
+ capExited(max: number): TtySession[];
513
612
  disposeAll(): Promise<void>;
514
613
  }
515
614
  /** 导出仅供单测(test/host-frames.test.ts):帧校验 / 绑定 / 孤儿语义的行为护栏。 */
@@ -570,6 +669,14 @@ export declare class TtyServer {
570
669
  /** 围栏放行之后的实际握手(与上面的异步分支共用)。 */
571
670
  private finishUpgrade;
572
671
  private onConnection;
672
+ /**
673
+ * 摘掉同 sid 上残留的**只读保留**会话(D77):spawn / ssh 新建同名会话前调用。
674
+ *
675
+ * 不摘会真泄漏:`sessions.add()` 用同一个键把旧对象顶出表,而旧对象的虚拟屏与
676
+ * 256KB 环形缓冲再没有任何引用能释放它们(`retire` 是唯一的释放口)。只处理
677
+ * 保留态——活着的同 sid 会话属于「跨连接同名」的既有语义,不在这里动。
678
+ */
679
+ private retireStaleExited;
573
680
  /**
574
681
  * 解析帧里的 sid。返回:
575
682
  * { sid } 目标会话;
@@ -670,16 +777,28 @@ export declare class TtyServer {
670
777
  /** 会话退出事实 → exit 帧(恰好一次;本地 PTY 与 SSH 共用)。 */
671
778
  private watchDone;
672
779
  /**
673
- * 会话终局的**唯一出口**:退役 + 清理 + 给所有绑定连接发 exit 帧(恰好一次)。
780
+ * 会话终局的**唯一出口**:给所有绑定连接发 exit 帧(恰好一次)+ 转只读保留(D77)。
674
781
  *
675
782
  * `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
676
783
  * 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
677
784
  * 各客户端按自己的 sid 收址。
785
+ *
786
+ * **D77 起「进程退出」不再等于「退役」**:会话留在 `sessions` 表里转只读保留态,
787
+ * 读侧工具(tty_list / tty_capture / tty_screen / tty_expect)照常可用,写侧拒写,
788
+ * 直到显式关闭(tty_close / 面板关标签)或保留期到点(reapExited)。退役只剩
789
+ * `SessionManager.retire` 那一处(出表 + 释放屏)。
678
790
  */
679
791
  private finishSession;
680
792
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
681
793
  private attachOutput;
682
- /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。 */
794
+ /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。
795
+ *
796
+ * D76:`force` 是**终局路径专用**的开关。`finishSession` 必须先置 `closed`(否则终局
797
+ * 之后到达的字节会继续往合并窗口里塞),可它同时又要交出**已经攒在 `pendingOutput`
798
+ * 里的**那批输出——两者共用同一个 `closed` 判据时,尾巴会被下面这行自己的守卫整批
799
+ * 吞掉:进程「打印完就退出」时那正是崩溃堆栈的最后一行 / 命令的结论行。传 `force`
800
+ * 即「我知道它已 closed,这一批仍然要发」。
801
+ */
683
802
  private flushPendingOutput;
684
803
  close(): void;
685
804
  }