@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.js CHANGED
@@ -17,7 +17,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
17
17
  import { sanitizeJumpSpec, sanitizeProxyCommand, spawnSsh, sshTarget, expandHome, setCredentialResolver, setProxyCommandPolicy, validateJumpSpec, validateProxyCommand } from './ssh.js';
18
18
  import { sharedGrantStore, auditLoadedGrants, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
19
19
  import { probeSsh } from './probe.js';
20
- import { buildCommandSpawn, buildShellSpawn, defaultShellPath } from './shell-integration.js';
20
+ import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
21
21
  import { parseSshConfigDetailed } from './ssh-config.js';
22
22
  import { parseKnownHostsDetailed } from './known-hosts.js';
23
23
  import { TunnelManager } from './tunnels.js';
@@ -135,6 +135,43 @@ const CAP_PROXY_COMMAND = { env: 'DSH_TTY_ALLOW_PROXY_COMMAND', label: '代理
135
135
  const DEFAULT_MAX_SESSIONS = 4;
136
136
  /** 断线保活默认秒数(reconnectGraceSec;0 = 旧行为,断开立即结束会话)。 */
137
137
  const DEFAULT_RECONNECT_GRACE_SEC = 120;
138
+ /**
139
+ * 会话退出后的**只读保留策略**(D77)。
140
+ *
141
+ * 进程没了之后把会话留在 `sessions` 表里:`tty_list` / `tty_capture` / `tty_screen`
142
+ * 照常能读到它最后那些输出(用户上报的痛点原话:「结果明明就在那里但我看不到」——
143
+ * `tty_open` 跑一条命令,跑完会话就退役,AI 一个字符都取不回来,逼得人先开
144
+ * `/bin/sh` 再往里发命令)。
145
+ *
146
+ * 取 **∞ = 保留到显式关闭**(`tty_close` / 面板关标签 / 宿主重启),不由时间淘汰:
147
+ *
148
+ * - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
149
+ * 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
150
+ * - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
151
+ * 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
152
+ * 宿主 / 插件重启即清空,不会跨天累积;
153
+ * - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
154
+ * 正是想要的语义:近的才有人读。
155
+ *
156
+ * 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
157
+ * (回收器每轮都会调它)。导出仅供单测(test/host-frames.test.ts):到点退役与
158
+ * 条数上限的行为护栏。
159
+ */
160
+ export const EXITED_RETAIN_MS = Number.POSITIVE_INFINITY;
161
+ /**
162
+ * 只读保留的会话数上限(超出按最旧淘汰,见 SessionManager.capExited)。
163
+ *
164
+ * 与「并发会话上限」(maxSessions,默认 4)是两个口径:那个数**只数活着的会话**
165
+ * (retained 的不占名额,否则跑几条短命令就把面板顶成「会话数已达上限」,
166
+ * 比原缺陷更糟)。这里兜的是内存:每条 retained 约 = 256KB 环形缓冲 + 一块
167
+ * xterm-headless 虚拟屏,16 条仍在 ~20MB 量级。
168
+ *
169
+ * 为什么从 8 抬到 16(D77 补,2026-09-27 真机验收反馈):保留改成「留到显式关闭」
170
+ * 之后,上限就是唯一会**自动**挤掉结果的东西,而 agent 一口气开二十几条一次性
171
+ * 会话是常态——8 条意味着「几分钟前那份结果」被最旧淘汰挤掉,用户只看到「没了」。
172
+ * 淘汰一律 `logger.warn` 留痕(见 finishSession),否则这件事在事后完全不可查。
173
+ */
174
+ export const MAX_EXITED_SESSIONS = 16;
138
175
  /** 下行背压阈值(ws.bufferedAmount 字节)。 */
139
176
  const BACKPRESSURE_HIGH = 512 * 1024;
140
177
  const BACKPRESSURE_LOW = 128 * 1024;
@@ -229,6 +266,45 @@ export function killLocalShellTerminal(terminal, platform = process.platform) {
229
266
  /* 已退出 */
230
267
  }
231
268
  }
269
+ /**
270
+ * Windows 本地 PTY 的**输入归一化**(D74,2026-09-27 真机报告):conhost 把 Enter 当
271
+ * **CR**,裸 LF 只把光标下移一格、**不提交命令行**——于是 `tty_send` 按工具描述发
272
+ * `echo X\n` 时,命令停在输入行上:没有输出、没有新提示符,看起来像「发出去了但没执行」。
273
+ *
274
+ * 修法取报告建议里改动最小的一条:win32 上把行尾补成 CRLF(已有 CR 的不重复补),
275
+ * 让「照描述写 `\n`」这条主路径直接可用。非 win32 原样透传(POSIX 的 Enter 就是 LF),
276
+ * SSH 会话也不归一化(远端是什么系统、什么 shell 插件不知道,乱改会破坏 `cat` 之类的原始输入)。
277
+ *
278
+ * 导出仅供单测:平台参数注入,两个分支都能在 macOS/Linux 断言。
279
+ */
280
+ export function normalizePtyInput(data, platform = process.platform) {
281
+ if (platform !== 'win32')
282
+ return data;
283
+ return data.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
284
+ }
285
+ /** 退出事实的一句话描述(工具文案共用同一措辞:`exitCode=3` / `signal=SIGSEGV`)。 */
286
+ function describeExit(exited) {
287
+ if (exited.signal !== null && exited.signal !== '')
288
+ return `signal=${exited.signal}`;
289
+ return exited.code === null ? '退出码未知' : `exitCode=${String(exited.code)}`;
290
+ }
291
+ /**
292
+ * 只读保留的剩余毫秒(D77);`null` = **不按时间释放**(策略为 ∞)或不是保留态。
293
+ *
294
+ * 工具结果带上它,agent 才知道「这个 sid 还能读多久」——否则它会以为读到的
295
+ * 是一具刚刚咽气的尸体、下次照样能读,而实际上屏与缓冲到点就释放了。
296
+ *
297
+ * 注意这个值会进工具输出:**不能返回 `Infinity`**——`JSON.stringify` 会把它变成
298
+ * `null`,撞上宿主对 `output.schema`(`type: 'number'`)的校验,整个工具调用直接
299
+ * 变成 Error(B33 抓过同一类)。所以「无限期」一律用**省略该字段**表达。
300
+ */
301
+ function retainLeftMs(session, now = Date.now()) {
302
+ if (session.exited === null)
303
+ return null;
304
+ if (!Number.isFinite(EXITED_RETAIN_MS))
305
+ return null;
306
+ return Math.max(0, session.exited.at + EXITED_RETAIN_MS - now);
307
+ }
232
308
  /** TERM/COLORTERM 值白名单校验:不合法回退 fallback(防止破坏 -c 包装层)。 */
233
309
  function sanitizeTermValue(value, fallback) {
234
310
  const trimmed = value.trim();
@@ -585,8 +661,9 @@ function advanceReadMark(session) {
585
661
  * 水位线之后的**未读**原始输出。下界再抬到「最后一个 B 标记之后」:回显落在
586
662
  * A(prompt 开始)与 B(命令开始)之间,所以这样能零启发式地排除回显——按
587
663
  * 文本比对「跳过与发送内容相同的行」会被折行 / ANSI / 多行粘贴打碎。
588
- * 没有 B 标记(未开 shell 集成 / Windows PowerShell / 远端未装集成)时无法
589
- * 区分回显,退化为从水位线起扫(可能匹配到回显本身,文档已声明)。
664
+ * 没有 B 标记(未开 shell 集成 / Windows PowerShell / 远端未装集成)时无法按边界切片:
665
+ * D75 起改用「最近提交过的输入」这份回显候选把回显行从匹配窗口里剔掉(见 echoOnlyMatch),
666
+ * 不再直接拿回显当命中。
590
667
  */
591
668
  function unreadRegion(session) {
592
669
  ensureReadMark(session);
@@ -607,6 +684,84 @@ function testPattern(re, text) {
607
684
  re.lastIndex = 0;
608
685
  return re.test(cleanAnsiTail(text));
609
686
  }
687
+ /* ------------------------------------------------------------------ *
688
+ * 回显命中剔除(D75)
689
+ *
690
+ * 症状:`tty_expect` 会命中**命令回显本身**——命令还没执行(Windows 上裸 LF 不提交,
691
+ * 见 D74;或只是被 shell 集成之外的环境回显),`tty_send` 写进去的那行文本先回到了
692
+ * 输出流里,于是「等就绪标记」立刻返回 matched。报告侧的现场:LF 没提交、命令一次
693
+ * 都没跑,`tty_expect pattern="echo ECHO_DEMO_2"` 秒回 matched(来源标注 buffered)。
694
+ *
695
+ * 判据(不动 B 标记那条零启发式路径,只补它够不着的场景):把**最近提交过的命令行**
696
+ * 从候选文本里削掉再试一次,只有「削掉后不再命中」才算纯回显。这样:
697
+ * - 真实输出里也出现的标记照旧命中(不误杀);
698
+ * - 只在回显行**末尾**匹配才削(cmd 的回显与提示符同行,所以按行尾切);
699
+ * - 有 B 标记 / 命令正在跑(TUI 全屏重画里出现输入文本是正常输出)时不启用。
700
+ * ------------------------------------------------------------------ */
701
+ /** 回显候选的行数上限(最近几条提交的输入)与单行长度上限。 */
702
+ const ECHO_INPUT_CAP = 8;
703
+ const ECHO_LINE_CAP = 512;
704
+ /**
705
+ * 记录一次「提交过的输入」的回显候选(只在 `tty_send` 且带行尾时调用):
706
+ * 单键按键(TUI 的 `q` / 方向键)不是命令行,不记——否则一个 `q` 就能把
707
+ * 之后任何只匹配到 `q` 的等待吞掉。
708
+ */
709
+ function noteSubmittedInput(session, data) {
710
+ if (!/[\r\n]/.test(data))
711
+ return;
712
+ const lines = data
713
+ .split(/\r\n|\r|\n/)
714
+ .map((line) => line.trim())
715
+ // 控制字符(多为转义序列,如 `vim` 的 `\x1b:wq`)不做行编辑模拟,直接不记
716
+ .filter((line) => line !== '' && line.length <= ECHO_LINE_CAP && !/[\x00-\x1f\x7f]/.test(line));
717
+ if (lines.length === 0)
718
+ return;
719
+ session.recentInputs = [...session.recentInputs, ...lines].slice(-ECHO_INPUT_CAP);
720
+ }
721
+ /**
722
+ * 这批回显候选在当前现场是否该启用剔除。三条都得成立:
723
+ * - 有候选(没 `tty_send` 过就无从判断,保持原语义);
724
+ * - 命令没在跑(`inCommand`:TUI 重画 / 长任务把输入文本画到屏上是真实输出);
725
+ * - 候选文本里没有 B 标记(有 B 就说明 shell 集成在管边界,回显已被切掉)。
726
+ */
727
+ function echoStrippable(session, text) {
728
+ if (session.recentInputs.length === 0)
729
+ return false;
730
+ if (session.shellState.inCommand)
731
+ return false;
732
+ return lastCommandStart(text) === -1;
733
+ }
734
+ /**
735
+ * 把回显候选行从文本里削掉(保行结构)。按**行尾**匹配:cmd 的回显与提示符同行
736
+ * (`C:\>echo X`),所以只削后缀、保留提示符前缀。行尾空白(含 `\r`)先归一。
737
+ */
738
+ function stripEchoLines(text, echoes) {
739
+ if (echoes.length === 0)
740
+ return text;
741
+ const sorted = [...echoes].sort((a, b) => b.length - a.length);
742
+ return text
743
+ .split('\n')
744
+ .map((line) => {
745
+ const trimmed = line.replace(/[ \t\r]+$/, '');
746
+ for (const echo of sorted) {
747
+ if (trimmed.endsWith(echo))
748
+ return trimmed.slice(0, trimmed.length - echo.length);
749
+ }
750
+ return line;
751
+ })
752
+ .join('\n');
753
+ }
754
+ /**
755
+ * 命中是否**只**落在回显上(纯回显 → 不算命中)。
756
+ * 原始流与清洗后文本各削一次:回显行里可能夹着 ANSI(zsh 的 zle / 彩色提示符)。
757
+ */
758
+ function echoOnlyMatch(re, text, session) {
759
+ if (!echoStrippable(session, text))
760
+ return false;
761
+ const stripped = stripEchoLines(text, session.recentInputs);
762
+ const strippedClean = stripEchoLines(cleanAnsiTail(text), session.recentInputs);
763
+ return !testPattern(re, stripped) && !testPattern(re, strippedClean);
764
+ }
610
765
  /** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
611
766
  function sanitizeTunnels(input) {
612
767
  if (!Array.isArray(input))
@@ -1113,9 +1268,6 @@ export function writeToScreen(screen, heartbeat, text, onStall, stallMs = SCREEN
1113
1268
  heartbeat.watchdog.unref?.();
1114
1269
  }
1115
1270
  }
1116
- /* ------------------------------------------------------------------ *
1117
- * 会话管理
1118
- * ------------------------------------------------------------------ */
1119
1271
  /** 导出仅供单测(test/host-frames.test.ts):上限 / 孤儿回收 / grace 热改的行为护栏。 */
1120
1272
  export class SessionManager {
1121
1273
  sessions = new Map();
@@ -1137,8 +1289,29 @@ export class SessionManager {
1137
1289
  get count() {
1138
1290
  return this.sessions.size;
1139
1291
  }
1292
+ /** 活着的会话数(**不含**只读保留的,见 canSpawn)。 */
1293
+ get liveCount() {
1294
+ let live = 0;
1295
+ for (const session of this.sessions.values())
1296
+ if (session.exited === null)
1297
+ live += 1;
1298
+ return live;
1299
+ }
1300
+ /** 只读保留的会话数(D77)。 */
1301
+ get exitedCount() {
1302
+ let exited = 0;
1303
+ for (const session of this.sessions.values())
1304
+ if (session.exited !== null)
1305
+ exited += 1;
1306
+ return exited;
1307
+ }
1308
+ /**
1309
+ * 名额判据**只数活着的会话**(D77):只读保留的不占名额。不这样分的话,
1310
+ * 「跑几条短命令」就能把面板顶成「会话数已达上限」——用户一条会话都没开,
1311
+ * 比原来那个「AI 取不到结果」的缺陷更糟。
1312
+ */
1140
1313
  canSpawn() {
1141
- return this.sessions.size < this.limit;
1314
+ return this.liveCount < this.limit;
1142
1315
  }
1143
1316
  add(session) {
1144
1317
  this.sessions.set(session.id, session);
@@ -1149,8 +1322,19 @@ export class SessionManager {
1149
1322
  get(id) {
1150
1323
  return this.sessions.get(id);
1151
1324
  }
1152
- /** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist)。 */
1325
+ /** 会话的只读快照(SSH 会话无本地 pid,该字段省略;tmux 持久会话带 persist;
1326
+ * 只读保留态(D77)额外带 exited/exitCode|signal/retainMs)。 */
1153
1327
  snapshotOf(session) {
1328
+ const exitRetainMs = retainLeftMs(session);
1329
+ const exitInfo = session.exited === null
1330
+ ? {}
1331
+ : {
1332
+ exited: true,
1333
+ ...(session.exited.code === null ? {} : { exitCode: session.exited.code }),
1334
+ ...(session.exited.signal === null ? {} : { signal: session.exited.signal }),
1335
+ // 省略 = 不按时间释放(策略 ∞):不能塞 Infinity,理由见 retainLeftMs
1336
+ ...(exitRetainMs === null ? {} : { retainMs: exitRetainMs }),
1337
+ };
1154
1338
  const base = {
1155
1339
  sid: session.id,
1156
1340
  cwd: session.cwd,
@@ -1160,6 +1344,7 @@ export class SessionManager {
1160
1344
  lastOutputAt: session.lastOutputAt,
1161
1345
  owner: session.owner,
1162
1346
  ...(session.tmuxName !== null ? { persist: true } : {}),
1347
+ ...exitInfo,
1163
1348
  };
1164
1349
  return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
1165
1350
  }
@@ -1171,7 +1356,8 @@ export class SessionManager {
1171
1356
  listForAttach() {
1172
1357
  return [...this.sessions.values()].map((session) => ({
1173
1358
  ...this.snapshotOf(session),
1174
- attachable: session.clients.size === 0 && !session.closed,
1359
+ // 只读保留态不可 attach(没有活着的 PTY 可接):客户端据此不建幽灵标签
1360
+ attachable: session.clients.size === 0 && !session.closed && session.exited === null,
1175
1361
  }));
1176
1362
  }
1177
1363
  /** 遍历全部会话(状态条采集器的批量收尾等按会话维度的操作)。 */
@@ -1179,10 +1365,12 @@ export class SessionManager {
1179
1365
  for (const session of this.sessions.values())
1180
1366
  fn(session);
1181
1367
  }
1182
- /** 按 tmux 持久会话名查找存活会话(跨窗口共享用);不存在/已关闭返回 undefined。 */
1368
+ /** 按 tmux 持久会话名查找**活着**的会话(跨窗口共享用);不存在/已关闭/只读保留态返回 undefined。
1369
+ * D77:保留态必须排除——否则「同名 persistName 的新标签」会 rebind 到一具尸体上,
1370
+ * 拿到 ready 却永远没有输出。 */
1183
1371
  findByTmuxName(tmuxName) {
1184
1372
  for (const session of this.sessions.values()) {
1185
- if (session.tmuxName === tmuxName && !session.closed)
1373
+ if (session.tmuxName === tmuxName && !session.closed && session.exited === null)
1186
1374
  return session;
1187
1375
  }
1188
1376
  return undefined;
@@ -1227,6 +1415,11 @@ export class SessionManager {
1227
1415
  for (const session of [...this.sessions.values()]) {
1228
1416
  if (session.owner === 'agent')
1229
1417
  continue;
1418
+ // D77:只读保留态不归孤儿回收管——它可能本来就带着 orphanedAt(断线后
1419
+ // 才退出),被这里收掉就等于「退出即退役」,保留期形同虚设。它的到点
1420
+ // 退役由 reapExited 统一负责。
1421
+ if (session.exited !== null)
1422
+ continue;
1230
1423
  if (session.orphanedAt === null)
1231
1424
  continue;
1232
1425
  if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
@@ -1234,6 +1427,40 @@ export class SessionManager {
1234
1427
  }
1235
1428
  }
1236
1429
  }
1430
+ /**
1431
+ * 只读保留到点退役(D77;回收器每轮调用):超过保留期的会话出表 + 释放屏。
1432
+ *
1433
+ * 默认策略是 ∞(保留到显式关闭)⇒ 本方法是 no-op,条数由 `capExited` 兜;
1434
+ * 把 `EXITED_RETAIN_MS` 改成有限值它就照常工作(策略可调,通路留着)。
1435
+ *
1436
+ * **不 kill 进程**:这里收的全是已经退出的会话(进程早没了),`retire()` 就够;
1437
+ * 真退役(显式 `tty_close` / 面板关标签)走 `killSessionNow`,那条路要处理
1438
+ * tmux teardown 与 forceKill 的兜底。
1439
+ */
1440
+ reapExited(retainMs) {
1441
+ const now = Date.now();
1442
+ for (const session of [...this.sessions.values()]) {
1443
+ if (session.exited === null)
1444
+ continue;
1445
+ if (retainMs <= 0 || now - session.exited.at >= retainMs)
1446
+ this.retire(session);
1447
+ }
1448
+ }
1449
+ /**
1450
+ * 只读保留的数量上限(超出按最旧淘汰,D77):返回被淘汰的会话,便于单测断言。
1451
+ *
1452
+ * 为什么必须有:`owner:'agent'` 的会话不会走孤儿回收,agent 若不显式 `tty_close`
1453
+ * (它常常不会),保留态就是**永久泄漏**——屏与 256KB 缓冲一直挂着。
1454
+ */
1455
+ capExited(max) {
1456
+ const exited = [...this.sessions.values()]
1457
+ .filter((session) => session.exited !== null)
1458
+ .sort((a, b) => (a.exited?.at ?? 0) - (b.exited?.at ?? 0));
1459
+ const victims = exited.slice(0, Math.max(0, exited.length - max));
1460
+ for (const session of victims)
1461
+ this.retire(session);
1462
+ return victims;
1463
+ }
1237
1464
  async disposeAll() {
1238
1465
  const all = [...this.sessions.values()];
1239
1466
  this.sessions.clear();
@@ -1246,6 +1473,9 @@ export class SessionManager {
1246
1473
  catch {
1247
1474
  /* 已释放 */
1248
1475
  }
1476
+ // D77:只读保留态(进程已退出)没有要收的进程——`forceKill` 只会对死句柄再戳一遍
1477
+ if (session.exited !== null)
1478
+ return Promise.resolve(true);
1249
1479
  return forceKill(session.handle);
1250
1480
  }));
1251
1481
  }
@@ -1351,7 +1581,8 @@ export class TtyServer {
1351
1581
  * 隐藏状态条,PTY 数据路径与终端体验完全不受影响。
1352
1582
  */
1353
1583
  startStats(session) {
1354
- if (!this.statsOn || session.closed || session.stats !== null || session.statsFailed)
1584
+ // D77:只读保留态没有进程可采(本地会取到宿主、远端 channel 早断了)——直接不起表
1585
+ if (!this.statsOn || session.closed || session.exited !== null || session.stats !== null || session.statsFailed)
1355
1586
  return;
1356
1587
  if (session.kind === 'local') {
1357
1588
  const sampler = localStatsSampler();
@@ -1545,6 +1776,20 @@ export class TtyServer {
1545
1776
  this.ctx.logger.warn('[dsh-tty] ws error: ' + error.message);
1546
1777
  });
1547
1778
  }
1779
+ /**
1780
+ * 摘掉同 sid 上残留的**只读保留**会话(D77):spawn / ssh 新建同名会话前调用。
1781
+ *
1782
+ * 不摘会真泄漏:`sessions.add()` 用同一个键把旧对象顶出表,而旧对象的虚拟屏与
1783
+ * 256KB 环形缓冲再没有任何引用能释放它们(`retire` 是唯一的释放口)。只处理
1784
+ * 保留态——活着的同 sid 会话属于「跨连接同名」的既有语义,不在这里动。
1785
+ */
1786
+ retireStaleExited(sid) {
1787
+ const stale = this.sessions.get(sid);
1788
+ if (stale === undefined || stale.exited === null)
1789
+ return;
1790
+ this.sessionLocals.get(stale)?.delete(stale.id);
1791
+ this.sessions.retire(stale);
1792
+ }
1548
1793
  /**
1549
1794
  * 解析帧里的 sid。返回:
1550
1795
  * { sid } 目标会话;
@@ -1650,6 +1895,8 @@ export class TtyServer {
1650
1895
  if (session.owner !== 'agent') {
1651
1896
  throw new Error(`会话 ${sid} 是用户在面板里开的(owner=user):请在面板里关闭那个标签,不要由 agent 越权结束`);
1652
1897
  }
1898
+ // D77:只读保留态(进程已退出)也走这条路——`killSessionNow` 对死句柄是安全的,
1899
+ // 它做的正是「退役 + 释放屏」。这也是 issue 里要的那个「显式关闭」入口。
1653
1900
  this.flushPendingOutput(session);
1654
1901
  this.killSessionNow(session);
1655
1902
  this.broadcastSessions();
@@ -1802,6 +2049,7 @@ export class TtyServer {
1802
2049
  handle,
1803
2050
  clients: new Map(),
1804
2051
  closed: false,
2052
+ exited: null,
1805
2053
  paused: false,
1806
2054
  owner,
1807
2055
  cwd,
@@ -1813,6 +2061,7 @@ export class TtyServer {
1813
2061
  outputSeq: 0,
1814
2062
  readSeq: -1,
1815
2063
  readMarkAt: 0,
2064
+ recentInputs: [],
1816
2065
  buffer: '',
1817
2066
  decoder: new StringDecoder('utf8'),
1818
2067
  screen: this.createScreen(clampInt(cols, 80, 2, 500), clampInt(rows, 24, 2, 200)),
@@ -1869,6 +2118,14 @@ export class TtyServer {
1869
2118
  session.statsSubs.clear();
1870
2119
  this.stopStats(session);
1871
2120
  this.sessions.retire(session);
2121
+ if (session.exited !== null) {
2122
+ // D77:只读保留态(进程已经退出)**只退役、不再碰句柄**。下面那几条收尾
2123
+ // (tmux kill-session / forceKill / done 兜底)语义都是「把一个还活着的 PTY
2124
+ // 收掉」;对一具尸体再戳一遍收益为零,而在个别后端(win-arm64 的 ConPTY)
2125
+ // 恰好是纯风险。tmux 那条也不做:D77 之前「退出」本来就不 teardown(tmux
2126
+ // 会话留存),保持一致。
2127
+ return;
2128
+ }
1872
2129
  const teardown = session.handle.tmuxTeardown;
1873
2130
  if (teardown !== undefined) {
1874
2131
  let settled = false;
@@ -1938,6 +2195,7 @@ export class TtyServer {
1938
2195
  send(ws, { t: 'error', sid, m: 'sid 已存在' });
1939
2196
  return;
1940
2197
  }
2198
+ this.retireStaleExited(sid); // D77:同 sid 上的只读保留态先摘掉,别让 add() 把它顶成泄漏
1941
2199
  // 持久化(0.10.0):配置 persistence=tmux 且帧带 persist 时,spawn 包装层
1942
2200
  // 换成 `exec tmux -L dsh-tty -A -s <名>`(tmux 托管);tmux 未安装则降级
1943
2201
  // 普通会话并回灰字提示。持久名稳定(客户端生成、随标签规格保存),
@@ -2012,6 +2270,7 @@ export class TtyServer {
2012
2270
  send(ws, { t: 'error', sid, m: 'sid 已存在' });
2013
2271
  return;
2014
2272
  }
2273
+ this.retireStaleExited(sid); // D77:同上,SSH 分支同规则
2015
2274
  // 跨窗口共享(0.10.1):同 tmuxName 的 SSH 持久会话还活着时不重建远程
2016
2275
  // 连接,本连接重绑定到现有会话(单 PTY 多客户端扇出)
2017
2276
  const parsedCommand = sanitizeCommand(msg.command);
@@ -2025,7 +2284,7 @@ export class TtyServer {
2025
2284
  : null;
2026
2285
  if (persistName !== null) {
2027
2286
  const existing = (this.sessions.findByTmuxName(persistName) ?? (await this.waitPendingTmux(persistName))) ?? null;
2028
- if (existing !== null && existing.kind === 'ssh' && !existing.closed) {
2287
+ if (existing !== null && existing.kind === 'ssh' && !existing.closed && existing.exited === null) {
2029
2288
  if (!conn.open)
2030
2289
  return; // 等待在途创建期间连接断了:不往死连接上绑
2031
2290
  this.rebindClient(existing, sid, ws, local, conn.id);
@@ -2073,6 +2332,7 @@ export class TtyServer {
2073
2332
  handle,
2074
2333
  clients: new Map([[conn.id + ':' + sid, { ws, sid }]]),
2075
2334
  closed: false,
2335
+ exited: null,
2076
2336
  paused: false,
2077
2337
  owner: 'user',
2078
2338
  cwd: '',
@@ -2084,6 +2344,7 @@ export class TtyServer {
2084
2344
  outputSeq: 0,
2085
2345
  readSeq: -1,
2086
2346
  readMarkAt: 0,
2347
+ recentInputs: [],
2087
2348
  buffer: '',
2088
2349
  decoder: new StringDecoder('utf8'),
2089
2350
  screen: this.createScreen(clampInt(msg.cols, 80, 2, 500), clampInt(msg.rows, 24, 2, 200)),
@@ -2146,7 +2407,8 @@ export class TtyServer {
2146
2407
  if (resolved === undefined || 'unknown' in resolved)
2147
2408
  return;
2148
2409
  const session = local.get(resolved.sid);
2149
- if (session !== undefined && !session.closed) {
2410
+ // D77:保留态是**只读**的——面板那边不该再发输入,真发了也不能写进死 PTY
2411
+ if (session !== undefined && !session.closed && session.exited === null) {
2150
2412
  session.lastInputAt = Date.now();
2151
2413
  await session.handle.write(data);
2152
2414
  }
@@ -2156,7 +2418,7 @@ export class TtyServer {
2156
2418
  if (resolved === undefined || 'unknown' in resolved)
2157
2419
  return;
2158
2420
  const session = local.get(resolved.sid);
2159
- if (session !== undefined) {
2421
+ if (session !== undefined && session.exited === null) {
2160
2422
  const cols = clampInt(msg.cols, 80, 2, 500);
2161
2423
  const rows = clampInt(msg.rows, 24, 2, 200);
2162
2424
  session.handle.resize(cols, rows);
@@ -2176,7 +2438,7 @@ export class TtyServer {
2176
2438
  if (resolved === undefined || 'unknown' in resolved)
2177
2439
  return;
2178
2440
  const session = local.get(resolved.sid);
2179
- if (session !== undefined && !session.closed && session.tmuxName !== null) {
2441
+ if (session !== undefined && !session.closed && session.exited === null && session.tmuxName !== null) {
2180
2442
  void session.handle.tmuxRefresh?.();
2181
2443
  }
2182
2444
  }
@@ -2225,6 +2487,12 @@ export class TtyServer {
2225
2487
  send(ws, { t: 'error', sid: raw, m: `会话不存在或已结束: ${raw}` });
2226
2488
  return;
2227
2489
  }
2490
+ if (session.exited !== null) {
2491
+ // D77:保留态没有活着的 PTY 可接回(缓冲里的输出由 tty_capture 读),
2492
+ // 如实说明而不是让客户端拿到一个永远不出输出的「假在线」标签
2493
+ send(ws, { t: 'error', sid: raw, m: `会话已退出(${describeExit(session.exited)}),只读保留中:不能再接回终端;要接着操作请重新打开标签或 tty_open 新会话` });
2494
+ return;
2495
+ }
2228
2496
  // 跨连接共享(0.10.1):tmux 持久会话允许多窗口同时绑定(单 PTY 扇出);
2229
2497
  // 非 tmux 会话仍独占(两个视图交错输入无意义)
2230
2498
  if (session.clients.size > 0 && session.tmuxName === null) {
@@ -2275,7 +2543,7 @@ export class TtyServer {
2275
2543
  if (resolved === undefined || 'unknown' in resolved)
2276
2544
  return;
2277
2545
  const session = local.get(resolved.sid);
2278
- if (session === undefined || session.closed)
2546
+ if (session === undefined || session.closed || session.exited !== null)
2279
2547
  return;
2280
2548
  this.setStatsSub(session, connId + ':' + resolved.sid, msg.t === 'statsOn');
2281
2549
  }
@@ -2287,35 +2555,47 @@ export class TtyServer {
2287
2555
  }).catch(() => { });
2288
2556
  }
2289
2557
  /**
2290
- * 会话终局的**唯一出口**:退役 + 清理 + 给所有绑定连接发 exit 帧(恰好一次)。
2558
+ * 会话终局的**唯一出口**:给所有绑定连接发 exit 帧(恰好一次)+ 转只读保留(D77)。
2291
2559
  *
2292
2560
  * `outcome` 正常来自 PTY 句柄的 done;显式 kill 的兜底(KILL_EXIT_FALLBACK_MS)
2293
2561
  * 也走这里,带 code=null / signal=SIGKILL。exit 广播到所有绑定连接(跨窗口共享),
2294
2562
  * 各客户端按自己的 sid 收址。
2563
+ *
2564
+ * **D77 起「进程退出」不再等于「退役」**:会话留在 `sessions` 表里转只读保留态,
2565
+ * 读侧工具(tty_list / tty_capture / tty_screen / tty_expect)照常可用,写侧拒写,
2566
+ * 直到显式关闭(tty_close / 面板关标签)或保留期到点(reapExited)。退役只剩
2567
+ * `SessionManager.retire` 那一处(出表 + 释放屏)。
2295
2568
  */
2296
2569
  finishSession(session, outcome) {
2297
2570
  if (session.exitSent === true)
2298
2571
  return;
2299
2572
  session.exitSent = true;
2300
- session.closed = true;
2301
2573
  session.statsSubs.clear();
2302
2574
  this.stopStats(session);
2303
- this.sessionLocals.get(session)?.delete(session.id);
2304
- this.sessions.remove(session.id);
2305
2575
  if (session.kind === 'ssh' && session.tmuxName !== null)
2306
2576
  this.trackPersist(session.tmuxName, false);
2577
+ // 心跳停掉,但**屏不 dispose**(D77):保留期内 tty_screen 还要读它。
2307
2578
  clearScreenWatchdog(session.screenHeartbeat);
2308
- try {
2309
- session.screen?.dispose();
2310
- }
2311
- catch {
2312
- /* 已释放 */
2313
- }
2314
- this.flushPendingOutput(session); // exit 前冲掉合并窗口里的尾巴,保序
2579
+ this.flushPendingOutput(session, true); // exit 前冲掉合并窗口里的尾巴,保序(D76:必须 force)
2315
2580
  for (const client of session.clients.values()) {
2316
2581
  send(client.ws, { t: 'exit', sid: client.sid, code: outcome.exitCode, signal: outcome.signal });
2317
2582
  }
2318
2583
  session.clients.clear();
2584
+ // ── 只读保留 ────────────────────────────────────────────────────────
2585
+ // 刻意**不**置 `closed`:那个位表示「真退役(出表 + 释放屏)」,读侧工具的
2586
+ // `session.closed` 守卫要放保留态过去。终局之后的字节因 clients 已清空而不再
2587
+ // 成帧(`onData` 照常入环形缓冲与屏:读到的是更完整的尾巴,不是更少的)。
2588
+ session.exited = { code: outcome.exitCode, signal: outcome.signal, at: Date.now() };
2589
+ for (const victim of this.sessions.capExited(MAX_EXITED_SESSIONS)) {
2590
+ // 被条数上限淘汰的:连同它在连接侧 local 表里的绑定一起摘掉(否则那条连接
2591
+ // 还拿得到 sid、却指着一个已退役的会话)
2592
+ this.sessionLocals.get(victim)?.delete(victim.id);
2593
+ // D77 补:淘汰必须留痕——否则用户侧只有「刚才那份结果怎么没了」这一种观测,
2594
+ // 排查时既不知道发生过淘汰、也不知道被挤掉的是哪个 sid。
2595
+ this.ctx.logger.warn(`[dsh-tty] 只读保留已达上限(${String(MAX_EXITED_SESSIONS)} 条,MAX_EXITED_SESSIONS):最旧的会话 ${victim.id} 被淘汰,它的输出不再可读;想留住结果请在淘汰前 tty_capture / tty_screen 读走`);
2596
+ }
2597
+ // 面板即时看到保留态(第二个窗口据此不建幽灵标签;agent 标签由 exit 帧标记)
2598
+ this.broadcastSessions();
2319
2599
  }
2320
2600
  /** 输出下行 + 基于 ws.bufferedAmount 的背压(暂停/恢复 PassThrough)。 */
2321
2601
  attachOutput(session) {
@@ -2379,15 +2659,22 @@ export class TtyServer {
2379
2659
  };
2380
2660
  output.on('data', onData);
2381
2661
  }
2382
- /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。 */
2383
- flushPendingOutput(session) {
2662
+ /** 立即冲刷待发的合并输出(exit/kill 前调用,保证 exit 帧永远在最后一帧 data 之后)。
2663
+ *
2664
+ * D76:`force` 是**终局路径专用**的开关。`finishSession` 必须先置 `closed`(否则终局
2665
+ * 之后到达的字节会继续往合并窗口里塞),可它同时又要交出**已经攒在 `pendingOutput`
2666
+ * 里的**那批输出——两者共用同一个 `closed` 判据时,尾巴会被下面这行自己的守卫整批
2667
+ * 吞掉:进程「打印完就退出」时那正是崩溃堆栈的最后一行 / 命令的结论行。传 `force`
2668
+ * 即「我知道它已 closed,这一批仍然要发」。
2669
+ */
2670
+ flushPendingOutput(session, force = false) {
2384
2671
  if (session.flushTimer !== null) {
2385
2672
  clearTimeout(session.flushTimer);
2386
2673
  session.flushTimer = null;
2387
2674
  }
2388
2675
  const pending = session.pendingOutput;
2389
2676
  session.pendingOutput = '';
2390
- if (session.closed || session.clients.size === 0 || pending === '')
2677
+ if ((session.closed && !force) || session.clients.size === 0 || pending === '')
2391
2678
  return;
2392
2679
  for (const client of session.clients.values()) {
2393
2680
  send(client.ws, { t: 'data', sid: client.sid, d: pending });
@@ -3796,7 +4083,7 @@ const plugin = definePlugin({
3796
4083
  name: 'tty_list',
3797
4084
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
3798
4085
  isConcurrencySafe: () => true,
3799
- description: '列出当前活跃的终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间)。用户开了终端面板后,用 tty_capture 读取某个 sid 的终端输出,用 tty_send 向该终端发送按键。',
4086
+ description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs):用户开了终端面板后,用 tty_capture 读取某个 sid 的输出、用 tty_send 向该会话发送按键。已退出的会话只能读(写会报错),要接着操作请 tty_open 新开一条。',
3800
4087
  parameters: {},
3801
4088
  output: {
3802
4089
  schema: {
@@ -3819,6 +4106,10 @@ const plugin = definePlugin({
3819
4106
  lastOutputAt: { type: 'number', required: true },
3820
4107
  persist: { type: 'boolean' },
3821
4108
  owner: { type: 'string', required: true },
4109
+ exited: { type: 'boolean' },
4110
+ exitCode: { type: 'number' },
4111
+ signal: { type: 'string' },
4112
+ retainMs: { type: 'number', description: '只读保留的剩余毫秒;省略 = 不按时间释放' },
3822
4113
  },
3823
4114
  },
3824
4115
  },
@@ -3827,12 +4118,16 @@ const plugin = definePlugin({
3827
4118
  render: (_args, value) => {
3828
4119
  const sessions = value?.sessions ?? [];
3829
4120
  const text = sessions.length === 0
3830
- ? '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
4121
+ ? '当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)'
3831
4122
  : '终端面板会话:' + sessions.map((s) => {
3832
4123
  const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
3833
4124
  const persist = s.persist === true ? ' [tmux 持久]' : '';
3834
4125
  const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
3835
- return `\n- sid=${s.sid} [${s.kind}]${owner}${persist} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
4126
+ // D77:只读保留态必须显眼——否则 AI 会对着一个已经死掉的会话发命令
4127
+ const detail = s.signal !== undefined && s.signal !== '' ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
4128
+ const left = s.retainMs === undefined ? '(显式关闭前一直都在)' : ` ${String(Math.ceil(s.retainMs / 60000))} 分钟`;
4129
+ const gone = s.exited === true ? ` [已退出 ${detail}·只读保留${left}——只能读,写会报错]` : '';
4130
+ return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
3836
4131
  }).join('');
3837
4132
  return [{ type: 'text', text }];
3838
4133
  },
@@ -3846,10 +4141,10 @@ const plugin = definePlugin({
3846
4141
  })));
3847
4142
  activeDisposers.push(tools.register(defineTool({
3848
4143
  name: 'tty_open',
3849
- description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条长驻命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。cwd 缺省为插件配置的工作目录。',
4144
+ description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按 **宿主 shell 的语法整段执行**(D78:旧版本里 `exec` 包装只跑第一条)——POSIX shell 上 `cd dir && cmd`、`a; b`、`for …; do …; done` 都可以,**Windows 的 cmd / PowerShell 则按它们自己的语法**(当前执行命令的 shell 与语法见 systemPrompt 里的终端一行);**它跑完退出后会话不会立刻消失**:会转成只读保留(留到显式关闭,最多留 16 条),退出前最后的输出与退出码都还能用 tty_capture / tty_screen 读——所以「跑一条会结束的命令、回头再取结果」不需要套一层 `sh`。cwd 缺省为插件配置的工作目录。',
3850
4145
  parameters: {
3851
4146
  cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
3852
- command: { type: 'string', description: '直接执行的命令(非交互);给出时不做 tmux 持久化。缺省 = 交互式 shell' },
4147
+ command: { type: 'string', description: '直接执行的命令(非交互):按**整段 shell 代码**执行,`cd x && cmd`、`a; b`、管道、多行脚本都可以(D78);给出时不做 tmux 持久化。省略或全空白 = 交互式 shell' },
3853
4148
  persistName: { type: 'string', description: 'tmux 持久会话名(开启「会话持久化」时有效;同名复用既有会话)。适合宿主重启后仍需存活的长任务' },
3854
4149
  cols: { type: 'number', description: '列数(2~500,默认 80)' },
3855
4150
  rows: { type: 'number', description: '行数(2~200,默认 24)' },
@@ -3865,7 +4160,7 @@ const plugin = definePlugin({
3865
4160
  },
3866
4161
  render: (_args, value) => {
3867
4162
  const v = value;
3868
- return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。` }];
4163
+ return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。\n${commandShellHint(live.shell)}` }];
3869
4164
  },
3870
4165
  },
3871
4166
  async execute(args) {
@@ -3881,7 +4176,7 @@ const plugin = definePlugin({
3881
4176
  })));
3882
4177
  activeDisposers.push(tools.register(defineTool({
3883
4178
  name: 'tty_close',
3884
- description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。',
4179
+ description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 16 条,超出按最旧淘汰。',
3885
4180
  parameters: {
3886
4181
  sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
3887
4182
  },
@@ -3959,6 +4254,11 @@ const plugin = definePlugin({
3959
4254
  const session = sessions.get(input.sid);
3960
4255
  if (session === undefined || session.closed)
3961
4256
  throw new Error(`会话不存在或已退出: ${input.sid}`);
4257
+ if (session.exited !== null) {
4258
+ // D77:进程没了就没有「这台会话所在机器」的此刻指标可言(本地会话会取到
4259
+ // 宿主自己、远端连接早断了)——如实回 available:false,不给过期数据
4260
+ return { sid: input.sid, available: false, reason: `会话的进程已退出(${describeExit(session.exited)}),只读保留中:取不到它的资源指标` };
4261
+ }
3962
4262
  const result = await server.sampleStats(session);
3963
4263
  if (result.available !== true || result.frame === undefined) {
3964
4264
  return { sid: input.sid, available: false, reason: result.reason ?? '未知原因' };
@@ -3970,7 +4270,7 @@ const plugin = definePlugin({
3970
4270
  name: 'tty_capture',
3971
4271
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
3972
4272
  isConcurrencySafe: () => true,
3973
- description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。',
4273
+ description: '读取某个终端面板会话(tty_list 提供 sid)的近期输出。默认读取尾部 N 行(60,最多 500,已剥离 ANSI 转义序列并收敛同行覆盖);last:true 时只返回「上一条已完成命令」的输出与退出码(依赖 shell 集成标记,更适合拿单条命令的结果)——若命令在途(刚发送/未收到完成标记)返回 inProgress:true 且不携带旧结果,请稍后重试或改用 tty_expect。**进程已退出的会话也能读**(结果带 exited:true + 退出码/信号):输出在只读保留期里一直都在(保留到显式关闭或宿主重启,`tty_open command=...` 跑完一条命令后就这么用);这类会话不能再写,要接着操作请 tty_open 新开一条。',
3974
4274
  parameters: {
3975
4275
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
3976
4276
  lines: { type: 'number', description: '读取尾部行数(1~500,默认 60);last:true 时忽略' },
@@ -3987,6 +4287,8 @@ const plugin = definePlugin({
3987
4287
  source: { type: 'string' },
3988
4288
  exitCode: { type: 'number' },
3989
4289
  inProgress: { type: 'boolean' },
4290
+ exited: { type: 'boolean' },
4291
+ signal: { type: 'string' },
3990
4292
  },
3991
4293
  },
3992
4294
  render: (_args, value) => {
@@ -3997,7 +4299,11 @@ const plugin = definePlugin({
3997
4299
  const head = v.source === 'last'
3998
4300
  ? `终端会话 ${v.sid ?? '?'} 上一条命令的输出(exitCode=${String(v.exitCode ?? '?')}):\n\n`
3999
4301
  : `终端会话 ${v.sid ?? '?'} 尾部输出:\n\n`;
4000
- return [{ type: 'text', text: head + (v.tail ?? '') }];
4302
+ // D77:读的是已退出会话时显式说明——免得 AI 把它当成「还在跑的终端」继续发命令
4303
+ const note = v.exited === true
4304
+ ? `(⚠️ 该会话的进程已退出${v.signal !== undefined && v.signal !== '' ? `(signal=${v.signal})` : ''},这是只读保留的输出;此会话不能再写入,要接着操作请 tty_open 新开一条)\n\n`
4305
+ : '';
4306
+ return [{ type: 'text', text: note + head + (v.tail ?? '') }];
4001
4307
  },
4002
4308
  },
4003
4309
  async execute(args) {
@@ -4008,6 +4314,10 @@ const plugin = definePlugin({
4008
4314
  if (session === undefined || session.closed)
4009
4315
  throw new Error(`会话不存在或已退出: ${input.sid}`);
4010
4316
  const useRaw = input.raw === true;
4317
+ // D77:只读保留态的标记(读得到,但要如实告诉 agent 这是已退出会话的输出)
4318
+ const exitMark = session.exited === null
4319
+ ? {}
4320
+ : { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) };
4011
4321
  // D72:读侧工具返回前推进水位线——这里读到的内容算「已读」,后续
4012
4322
  // tty_expect 不再把它们当未读回扫(想回看更早的内容再用本工具)。
4013
4323
  // tty_screen 刻意**不**推进:它是「当前可见屏幕」这一种表示,不是
@@ -4030,18 +4340,24 @@ const plugin = definePlugin({
4030
4340
  // 收一刀也保尾——最近输出才是 agent 要的。
4031
4341
  // exitCode 用「键不存在」表达缺失(`?? undefined` 会留下一个
4032
4342
  // undefined 键,不是无损 JSON 值,宿主输出校验会判工具错,见 B33)。
4033
- return { sid: input.sid, source: 'last', ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
4343
+ return { sid: input.sid, source: 'last', ...exitMark, ...(last.exitCode === null ? {} : { exitCode: last.exitCode }), tail: (useRaw ? last.output : cleanAnsiTail(last.output)).slice(-128 * 1024) };
4034
4344
  }
4035
4345
  const lines = Math.max(1, Math.min(500, typeof input.lines === 'number' && Number.isInteger(input.lines) && input.lines >= 1 ? input.lines : 60));
4036
4346
  const rawTail = tailLines(session, lines);
4037
- return { sid: input.sid, source: 'tail', tail: useRaw ? rawTail : cleanAnsiTail(rawTail) };
4347
+ return {
4348
+ sid: input.sid,
4349
+ source: 'tail',
4350
+ ...exitMark,
4351
+ ...(session.exited === null || session.exited.code === null ? {} : { exitCode: session.exited.code }),
4352
+ tail: useRaw ? rawTail : cleanAnsiTail(rawTail),
4353
+ };
4038
4354
  },
4039
4355
  })));
4040
4356
  activeDisposers.push(tools.register(defineTool({
4041
4357
  name: 'tty_screen',
4042
4358
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4043
4359
  isConcurrencySafe: () => true,
4044
- description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。',
4360
+ description: '读取某个终端面板会话(tty_list 提供 sid)当前可见屏幕的渲染结果(纯文本,等价于用户此刻看到的画面)。适合查看全屏交互程序(vim / htop / 菜单选择)的当前界面状态;要历史滚动输出用 tty_capture。**进程已退出的会话也能读**(结果带 exited:true):屏在只读保留期内不释放,读到的就是它退出那一刻的画面。',
4045
4361
  parameters: {
4046
4362
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
4047
4363
  },
@@ -4054,11 +4370,16 @@ const plugin = definePlugin({
4054
4370
  cols: { type: 'number', required: true },
4055
4371
  rows: { type: 'number', required: true },
4056
4372
  text: { type: 'string', required: true },
4373
+ exited: { type: 'boolean' },
4374
+ signal: { type: 'string' },
4057
4375
  },
4058
4376
  },
4059
4377
  render: (_args, value) => {
4060
4378
  const v = value;
4061
- return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')}):\n\n${v.text ?? ''}` }];
4379
+ const note = v.exited === true
4380
+ ? `(⚠️ 该会话的进程已退出${v.signal !== undefined && v.signal !== '' ? `(signal=${v.signal})` : ''}:这是退出那一刻的屏幕,只读保留中)`
4381
+ : '';
4382
+ return [{ type: 'text', text: `终端会话 ${v.sid ?? '?'} 当前屏幕(${String(v.cols ?? '?')}×${String(v.rows ?? '?')})${note}:\n\n${v.text ?? ''}` }];
4062
4383
  },
4063
4384
  },
4064
4385
  async execute(args) {
@@ -4082,14 +4403,21 @@ const plugin = definePlugin({
4082
4403
  while (lines.length > 0 && lines[lines.length - 1].trim() === '')
4083
4404
  lines.pop();
4084
4405
  // 保尾截断:屏幕末尾(提示符行)才是有效区,丢头部不丢尾部
4085
- return { sid: input.sid, cols: screen.cols, rows: screen.rows, text: lines.join('\n').slice(-32 * 1024) };
4406
+ return {
4407
+ sid: input.sid,
4408
+ cols: screen.cols,
4409
+ rows: screen.rows,
4410
+ // D77:只读保留态如实标注(屏不再更新,「当前屏幕」= 退出那一刻)
4411
+ ...(session.exited === null ? {} : { exited: true, ...(session.exited.signal === null || session.exited.signal === '' ? {} : { signal: session.exited.signal }) }),
4412
+ text: lines.join('\n').slice(-32 * 1024),
4413
+ };
4086
4414
  },
4087
4415
  })));
4088
4416
  activeDisposers.push(tools.register(defineTool({
4089
4417
  name: 'tty_expect',
4090
4418
  // 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
4091
4419
  isConcurrencySafe: () => true,
4092
- description: '在某个终端面板会话(tty_list 提供 sid)等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出),再等后续输出——所以命令瞬间跑完也不会白等;匹配到立即返回 matched:true(matchedFrom 说明匹配来自哪里:live=本次等待期间新产生 / last=上一条命令的输出 / buffered=此前已到达的缓冲输出)与周边输出。超时不抛错,返回 matched:false + 尾部输出;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。注意:只对「还没读过的输出」负责——要回看更早的内容用 tty_capture。',
4420
+ description: '在某个终端面板会话(tty_list 提供 sid)等待一个正则出现(如 dev server 的 ready/URL、构建完成标记、交互提示)。**先回溯**还没被读过的输出(含「上一条命令」的完整输出),再等后续输出——所以命令瞬间跑完也不会白等;匹配到立即返回 matched:true(matchedFrom 说明匹配来自哪里:live=本次等待期间新产生 / last=上一条命令的输出 / buffered=此前已到达的缓冲输出)与周边输出。**刚发进去的命令回显不算命中**(命令还没执行时回显先到,命中它等于谎报);若等满超时且 pattern 只命中过回显,结果带 echoOnly:true——那说明命中的只是回显、不是输出(命令可能没被执行),先复核它到底跑没跑。超时不抛错,返回 matched:false + 尾部输出;期间该命令若已结束(shell 集成标记)也会提前返回并带退出码。适合先 tty_send 启动长任务、再 tty_expect 等就绪信号的流程。注意:只对「还没读过的输出」负责——要回看更早的内容用 tty_capture。',
4093
4421
  parameters: {
4094
4422
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
4095
4423
  pattern: { type: 'string', required: true, description: '等待匹配的正则表达式(JavaScript RegExp 语法)' },
@@ -4105,6 +4433,8 @@ const plugin = definePlugin({
4105
4433
  text: { type: 'string', required: true },
4106
4434
  exitCode: { type: 'number' },
4107
4435
  matchedFrom: { type: 'string' },
4436
+ echoOnly: { type: 'boolean' },
4437
+ exited: { type: 'boolean' },
4108
4438
  },
4109
4439
  },
4110
4440
  render: (_args, value) => {
@@ -4115,6 +4445,14 @@ const plugin = definePlugin({
4115
4445
  : v.matchedFrom === 'buffered' ? '(回溯自此前已到达、还没读过的缓冲输出)' : '';
4116
4446
  return [{ type: 'text', text: `已匹配到等待的模式${from}:\n\n${v.text ?? ''}` }];
4117
4447
  }
4448
+ if (v.echoOnly === true) {
4449
+ // D75:只命中过回显 —— 这不是「命令跑了但没输出」,而是「等的东西一次都没出现在输出里」
4450
+ return [{ type: 'text', text: `等待超时:pattern **只匹配到刚发进去的命令回显**,没有匹配到任何真正的输出——命令可能没有被执行(回显先到、输出没来),也可能执行了但输出里没有这个 pattern。先复核它到底跑没跑(tty_capture 读尾部、或在面板里看那一行是否还停在输入行),再决定重发命令还是换 pattern。尾部输出:\n\n${v.text ?? ''}` }];
4451
+ }
4452
+ if (v.exited === true) {
4453
+ // D77:会话的进程已经退出(只读保留)——不会再有任何新输出,别让它以为还能等
4454
+ return [{ type: 'text', text: `会话的进程已退出(只读保留中),不会再有新输出:这一轮按现存输出结算,没有匹配到 pattern。要看它最后的输出用 tty_capture(默认读尾部)/ tty_screen(退出那一刻的屏);要接着跑命令请 tty_open 新开一条会话。尾部输出:\n\n${v.text ?? ''}` }];
4455
+ }
4118
4456
  if (v.timedOut === true && (v.text ?? '').trim() === '') {
4119
4457
  // D72:这段空白此前被当成「命令没执行」——命令若是瞬间完成的,它早就跑完了
4120
4458
  return [{ type: 'text', text: '等待超时,且注册之后没有任何新输出。命令若是瞬间完成的,它早已在开始等待之前跑完:用 tty_capture{last:true} 复核那条命令的输出与退出码,别把这段空白当成「没执行」。' }];
@@ -4155,6 +4493,8 @@ const plugin = definePlugin({
4155
4493
  // 尾部窗口:匹配只看最近 16KB,acc 全量囤积对刷屏会话可涨到数百 MB
4156
4494
  let acc = '';
4157
4495
  let settled = false;
4496
+ /** D75:pattern 只命中过回显(被剔除了)——超时文案据此如实说明「命令可能没跑」。 */
4497
+ let sawEchoOnly = false;
4158
4498
  let timer = null;
4159
4499
  const decoder = new StringDecoder('utf8');
4160
4500
  const output = session.handle.output;
@@ -4177,18 +4517,25 @@ const plugin = definePlugin({
4177
4517
  advanceReadMark(session);
4178
4518
  resolve(result);
4179
4519
  };
4180
- function onData(chunk) {
4520
+ // 箭头函数(不是 function 声明):后者会被提升,TS 不保留外层 `const session`
4521
+ // 的收窄,闭包里就得再判一次 undefined。
4522
+ const onData = (chunk) => {
4181
4523
  acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
4182
4524
  const hay = acc.length > 16 * 1024 ? acc.slice(-16 * 1024) : acc;
4183
4525
  if (testPattern(re, hay)) {
4184
- finish({ matched: true, timedOut: false, matchedFrom: 'live', text: cleanAnsiTail(hay.slice(-6 * 1024)) });
4185
- return;
4526
+ // D75:命中的若**只是刚送进去那行命令的回显**,不算命中——继续等真输出
4527
+ if (echoOnlyMatch(re, hay, session))
4528
+ sawEchoOnly = true;
4529
+ else {
4530
+ finish({ matched: true, timedOut: false, matchedFrom: 'live', text: cleanAnsiTail(hay.slice(-6 * 1024)) });
4531
+ return;
4532
+ }
4186
4533
  }
4187
4534
  // 命令早停:注册时命令在飞(B..D 之间),如今 D 已到仍未匹配
4188
4535
  if (startedInCommand && !state.inCommand && state.lastCommand !== null && state.lastCommand.endedAt >= startedAt) {
4189
- finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
4536
+ finish({ matched: false, timedOut: false, ...(state.lastCommand.exitCode === null ? {} : { exitCode: state.lastCommand.exitCode }), ...(sawEchoOnly ? { echoOnly: true } : {}), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
4190
4537
  }
4191
- }
4538
+ };
4192
4539
  // ── 注册**之前**就已到达的输出(D72)─────────────────────────
4193
4540
  // 这段此前完全不匹配:acc 从空开始,所以「命令瞬间完成」时标记
4194
4541
  // 早就躺在缓冲区里、acc 里永远没有它 → 白等满超时、还只返回空白。
@@ -4204,23 +4551,30 @@ const plugin = definePlugin({
4204
4551
  // ② 泛化:水位线之后的未读缓冲(长驻输出落在多条命令之间、无 shell 集成……)
4205
4552
  const backlog = unreadRegion(session);
4206
4553
  if (backlog !== '' && testPattern(re, backlog)) {
4207
- finish({ matched: true, timedOut: false, matchedFrom: 'buffered', text: cleanAnsiTail(backlog.slice(-6 * 1024)) });
4208
- return;
4554
+ // D75:同上——纯回显不算命中(Windows 上 LF 没提交时,这里命中的就只有回显)
4555
+ if (echoOnlyMatch(re, backlog, session))
4556
+ sawEchoOnly = true;
4557
+ else {
4558
+ finish({ matched: true, timedOut: false, matchedFrom: 'buffered', text: cleanAnsiTail(backlog.slice(-6 * 1024)) });
4559
+ return;
4560
+ }
4209
4561
  }
4210
4562
  timer = setTimeout(() => {
4211
- finish({ matched: false, timedOut: true, text: cleanAnsiTail(acc.slice(-6 * 1024)) }, false);
4563
+ finish({ matched: false, timedOut: true, ...(sawEchoOnly ? { echoOnly: true } : {}), text: cleanAnsiTail(acc.slice(-6 * 1024)) }, false);
4212
4564
  }, timeoutMs);
4213
4565
  timer.unref?.();
4214
4566
  output.on('data', onData);
4215
4567
  void session.handle.done.then(() => {
4216
- finish({ matched: false, timedOut: false, text: cleanAnsiTail(acc.slice(-6 * 1024)) });
4568
+ // 会话结束(命令跑完 / 进程退出):不能再等——D77 的只读保留态也走这里
4569
+ // (done 早已兑现,所以退出后调用 expect 不再白等满超时)
4570
+ finish({ matched: false, timedOut: false, ...(session.exited === null ? {} : { exited: true }), text: cleanAnsiTail(acc.slice(-6 * 1024)) });
4217
4571
  });
4218
4572
  });
4219
4573
  },
4220
4574
  })));
4221
4575
  activeDisposers.push(tools.register(defineTool({
4222
4576
  name: 'tty_send',
4223
- description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。',
4577
+ description: '向某个终端面板会话(tty_list 提供 sid)的 PTY 发送按键/文本(命令以 \\n 结尾;Windows 本地会话上插件会把 `\\n` 归一成 CRLF,照常写 `\\n` 即可)。适合给用户终端里运行的程序发交互输入(如 dev server 的 q 键、menu 选择、回答提示)。操作会实时显示在用户的终端面板里。**对已退出(只读保留)的会话会报错**——那种会话只能读,要接着操作请 tty_open 新开一条。',
4224
4578
  parameters: {
4225
4579
  sid: { type: 'string', required: true, description: '会话 id(来自 tty_list)' },
4226
4580
  data: { type: 'string', required: true, description: '要发送的文本(含换行则直接发送命令)' },
@@ -4248,12 +4602,21 @@ const plugin = definePlugin({
4248
4602
  const session = sessions.get(input.sid);
4249
4603
  if (session === undefined || session.closed)
4250
4604
  throw new Error(`会话不存在或已退出: ${input.sid}`);
4605
+ if (session.exited !== null) {
4606
+ // D77:只读保留态明确拒写——进程已经没了,写进去只会在死 PTY 上静默消失
4607
+ throw new Error(`会话 ${input.sid} 的进程已退出(${describeExit(session.exited)}),只读保留中,写不进去:要接着操作请 tty_open 新开一条会话(它最后的输出仍可用 tty_capture / tty_screen 读)`);
4608
+ }
4609
+ // D74:Windows 本地 PTY 的 Enter 是 CR,裸 LF 不提交命令行——按平台归一化。
4610
+ // SSH 会话不动:远端是什么系统插件不知道。
4611
+ const data = session.kind === 'local' ? normalizePtyInput(input.data) : input.data;
4251
4612
  session.lastInputAt = Date.now();
4252
4613
  // D72:只初始化水位线,**不推进**——刚发出去的这条命令的输出 AI 还没看见;
4253
4614
  // 但这一刻之前的积压不该被第一次 expect 当成「未读」回扫。
4254
4615
  ensureReadMark(session);
4255
- await session.handle.write(input.data);
4256
- return { ok: true, sent: input.data.length };
4616
+ // D75:记下这行命令的回显候选,供 tty_expect 剔除「命中回显」的假阳性
4617
+ noteSubmittedInput(session, data);
4618
+ await session.handle.write(data);
4619
+ return { ok: true, sent: data.length };
4257
4620
  },
4258
4621
  })));
4259
4622
  activeDisposers.push(tools.register(defineTool({
@@ -4727,13 +5090,30 @@ const plugin = definePlugin({
4727
5090
  order: 150,
4728
5091
  text: () => {
4729
5092
  const list = sessions.list();
5093
+ // D78 补:本地命令的语法随宿主 shell 变(POSIX / cmd / PowerShell),
5094
+ // 而「Shell 路径」是热改的配置——所以这行每轮现算,不冻在工具描述里。
5095
+ const shellLine = commandShellHint(live.shell);
4730
5096
  if (list.length === 0)
4731
- return '当前没有活跃的终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。';
4732
- return '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + list.map((s) => {
4733
- const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
4734
- const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
4735
- return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
4736
- }).join('\n');
5097
+ return `当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。\n${shellLine}`;
5098
+ // D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
5099
+ // 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
5100
+ // 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
5101
+ const alive = list.filter((s) => s.exited !== true);
5102
+ const gone = list.filter((s) => s.exited === true);
5103
+ const head = alive.length === 0
5104
+ ? '当前没有活着的终端面板会话。'
5105
+ : '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + alive.map((s) => {
5106
+ const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
5107
+ const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
5108
+ return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
5109
+ }).join('\n');
5110
+ const tail = gone.length === 0
5111
+ ? ''
5112
+ : `\n另有 ${String(gone.length)} 条已退出但输出仍可读的会话(只读:tty_send 会报错,要用 tty_capture / tty_screen 读):` + gone.map((s) => {
5113
+ const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
5114
+ return `sid=${s.sid} (${how})`;
5115
+ }).join('、');
5116
+ return head + tail + '\n' + shellLine;
4737
5117
  },
4738
5118
  });
4739
5119
  sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
@@ -4765,6 +5145,7 @@ const plugin = definePlugin({
4765
5145
  // 断开时立即结束);插件卸载时随 effect 一起停掉
4766
5146
  const reaperTimer = setInterval(() => {
4767
5147
  void sessions.reapOrphans(live.reconnectGraceMs);
5148
+ sessions.reapExited(EXITED_RETAIN_MS); // D77:保留期策略为有限值时到点退役(∞ 时不动作,条数由 capExited 兜)
4768
5149
  }, REAPER_INTERVAL_MS);
4769
5150
  reaperTimer.unref?.();
4770
5151
  ctx.effect(() => () => clearInterval(reaperTimer), 'dsh-tty: orphan reaper');