@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/README.en.md +68 -10
- package/README.md +57 -10
- package/client.js +16 -1
- package/lib/index.d.ts +144 -25
- package/lib/index.js +445 -64
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.d.ts +11 -0
- package/lib/shell-integration.js +52 -1
- package/lib/shell-integration.js.map +1 -1
- package/package.json +2 -2
- package/scripts/integration.mjs +132 -1
- package/scripts/windows-smoke.mjs +100 -1
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
? '
|
|
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
|
-
|
|
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`
|
|
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: '
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
4185
|
-
|
|
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
|
-
|
|
4208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4256
|
-
|
|
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
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
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');
|