@hyzyn/dsh-tty 0.22.0-rc.2 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +9 -4
- package/README.md +7 -4
- package/client.js +24 -24
- package/lib/index.d.ts +41 -4
- package/lib/index.js +118 -28
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.d.ts +11 -0
- package/lib/shell-integration.js +27 -0
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.js +72 -19
- package/lib/ssh.js.map +1 -1
- package/lib/tmux.d.ts +39 -3
- package/lib/tmux.js +82 -26
- package/lib/tmux.js.map +1 -1
- package/package.json +2 -2
- package/scripts/preview/harness.js +51 -0
- package/scripts/preview/mock-host.js +7 -1
- package/scripts/preview.mjs +1 -0
package/lib/index.d.ts
CHANGED
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
*/
|
|
69
69
|
import type { Context } from '@deepseek-ai/cordis';
|
|
70
70
|
import z from '@deepseek-ai/schemastery';
|
|
71
|
+
import { PassThrough } from 'node:stream';
|
|
71
72
|
import { StringDecoder } from 'node:string_decoder';
|
|
72
73
|
import WebSocket from 'ws';
|
|
73
74
|
import xtermHeadless from '@xterm/headless';
|
|
@@ -155,10 +156,10 @@ export declare const Config: z;
|
|
|
155
156
|
*
|
|
156
157
|
* - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
|
|
157
158
|
* 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
|
|
158
|
-
* - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(
|
|
159
|
+
* - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
|
|
159
160
|
* 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
|
|
160
161
|
* 宿主 / 插件重启即清空,不会跨天累积;
|
|
161
|
-
* - 连续跑很多短命令时,淘汰节奏变成「超过
|
|
162
|
+
* - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
|
|
162
163
|
* 正是想要的语义:近的才有人读。
|
|
163
164
|
*
|
|
164
165
|
* 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
|
|
@@ -180,6 +181,24 @@ export declare const EXITED_RETAIN_MS: number;
|
|
|
180
181
|
* 淘汰一律 `logger.warn` 留痕(见 finishSession),否则这件事在事后完全不可查。
|
|
181
182
|
*/
|
|
182
183
|
export declare const MAX_EXITED_SESSIONS = 16;
|
|
184
|
+
/** DSH spawnTerminal 返回 handle 的最小形状(含内部耦合的 terminal 字段)。 */
|
|
185
|
+
interface PtyHandle {
|
|
186
|
+
pid: number;
|
|
187
|
+
output: PassThrough;
|
|
188
|
+
write(data: string): Promise<unknown>;
|
|
189
|
+
terminate(): Promise<unknown>;
|
|
190
|
+
done: Promise<{
|
|
191
|
+
exitCode: number | null;
|
|
192
|
+
signal: string | null;
|
|
193
|
+
}>;
|
|
194
|
+
/** 内部耦合:DSH 的 LocalTerminalHandle 未暴露 resize/kill,直接透传 node-pty。 */
|
|
195
|
+
terminal?: {
|
|
196
|
+
resize?(cols: number, rows: number): void;
|
|
197
|
+
kill?(signal: string): void;
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
201
|
+
export declare function wrapLocalPty(handle: PtyHandle): TermHandle;
|
|
183
202
|
/**
|
|
184
203
|
* 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
|
|
185
204
|
*
|
|
@@ -305,6 +324,10 @@ interface TtySession {
|
|
|
305
324
|
stats: StatsCollector | null;
|
|
306
325
|
/** 采集已永久失败(远端无 /proc、exec 被拒、连接断开):不再重启,前端隐藏状态条。 */
|
|
307
326
|
statsFailed: boolean;
|
|
327
|
+
/** 采集失败后的重挂时刻(0 = 没有待重挂):失败位不再是粘性的(D83)。 */
|
|
328
|
+
statsRetryAt: number;
|
|
329
|
+
/** 连续失败次数:退避倍数按它递增,出过帧即归零。 */
|
|
330
|
+
statsFailures: number;
|
|
308
331
|
}
|
|
309
332
|
interface ReqLike {
|
|
310
333
|
method?: string;
|
|
@@ -655,10 +678,24 @@ export declare class TtyServer {
|
|
|
655
678
|
* 共享一次 df/netstat);
|
|
656
679
|
* - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
|
|
657
680
|
* 速率类由远端算好,宿主只解析 + 清洗。
|
|
658
|
-
*
|
|
659
|
-
*
|
|
681
|
+
* 失败时静默停表并置 statsFailed:前端靠「无数据」隐藏状态条,PTY 数据路径与
|
|
682
|
+
* 终端体验完全不受影响。
|
|
683
|
+
*
|
|
684
|
+
* 但失败位**不再粘死整个会话**(D83):原先置位后永不复位,一次瞬态故障
|
|
685
|
+
* (sshd MaxSessions 拒绝并发 channel、单通道 ECONNRESET)就让状态条与 agent
|
|
686
|
+
* tty_stats 在会话余生里彻底没有数据,而主 PTY 通道其实是健康的。现在按指数
|
|
687
|
+
* 退避自动重挂(出过帧即计数归零),既保住「别每秒重启」的本意,又能自愈。
|
|
660
688
|
*/
|
|
661
689
|
private startStats;
|
|
690
|
+
/**
|
|
691
|
+
* 采集失败后的退避重挂(D83)。
|
|
692
|
+
*
|
|
693
|
+
* 退避而不是立刻重试,是为了保住原先「粘性失败位」想解决的问题——远端平台压根
|
|
694
|
+
* 没有采集源(macOS/BSD:既无 /proc 也无 PowerShell)时不能每秒重启一个必然失败
|
|
695
|
+
* 的 channel。指数退避 + 上限把这种「稳态失败」压到几分钟一次,同时让瞬态故障
|
|
696
|
+
* 在恢复后自动回到有数据状态(出过帧就归零)。
|
|
697
|
+
*/
|
|
698
|
+
private scheduleStatsRetry;
|
|
662
699
|
/** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
|
|
663
700
|
private stopStats;
|
|
664
701
|
private stopAllStats;
|
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';
|
|
@@ -147,10 +147,10 @@ const DEFAULT_RECONNECT_GRACE_SEC = 120;
|
|
|
147
147
|
*
|
|
148
148
|
* - 时间上界对用户是**第二重惊喜**——「命令跑完 → 下一轮读结果」之间隔着人离开、
|
|
149
149
|
* 模型排队,多久都有可能;一个到期就消失的输出比「要主动关」更难理解;
|
|
150
|
-
* - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(
|
|
150
|
+
* - 内存与句柄本来也不由时间决定:条数由 [`MAX_EXITED_SESSIONS`](#) 兜(16 条,
|
|
151
151
|
* 单条几百 KB~一两 MB),而「永久」还有一条天然上界——保留是**内存态**,
|
|
152
152
|
* 宿主 / 插件重启即清空,不会跨天累积;
|
|
153
|
-
* - 连续跑很多短命令时,淘汰节奏变成「超过
|
|
153
|
+
* - 连续跑很多短命令时,淘汰节奏变成「超过 16 条按最旧淘汰」(`capExited`),
|
|
154
154
|
* 正是想要的语义:近的才有人读。
|
|
155
155
|
*
|
|
156
156
|
* 需要时间上界的人把这里改成任意毫秒数即可——`reapExited` 那条通路还在
|
|
@@ -212,10 +212,37 @@ const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
|
212
212
|
const REAPER_INTERVAL_MS = 10_000;
|
|
213
213
|
/** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
|
|
214
214
|
const STATS_INTERVAL_MS = 1000;
|
|
215
|
+
/**
|
|
216
|
+
* 采集失败后的退避重挂:`base × 2^(失败次数-1)`,上限 `MAX`(D83)。
|
|
217
|
+
*
|
|
218
|
+
* 上限 5 分钟是给「远端压根没有采集源」那种稳态失败留的——既要重挂(瞬态故障能自愈),
|
|
219
|
+
* 又不能把必然失败的 channel 重开得太勤。出过帧即计数归零,恢复正常节奏。
|
|
220
|
+
*/
|
|
221
|
+
const STATS_RETRY_BASE_MS = 30_000;
|
|
222
|
+
const STATS_RETRY_MAX_MS = 300_000;
|
|
223
|
+
/**
|
|
224
|
+
* agent `tty_stats` 一发式远端采样的超时(D84)。
|
|
225
|
+
*
|
|
226
|
+
* 原先写死 3s,而这条路径要等**第一帧**:远端 shell 启动 + 脚本首次迭代,Windows
|
|
227
|
+
* 远端还要算 PowerShell 冷启动 + 多次 WMI 查询——状态条那条推送路径没有这个上限,
|
|
228
|
+
* 于是出现过「面板有数据、agent tty_stats 每次都报超时」的错位。放宽到 15s:工具
|
|
229
|
+
* 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
|
|
230
|
+
*/
|
|
231
|
+
const STATS_ONESHOT_TIMEOUT_MS = 15_000;
|
|
215
232
|
const TTY_GUIDANCE = '本机已安装 dsh-tty 插件(终端面板):Web GUI 侧边栏的「终端」入口可打开交互终端(xterm.js + PTY),可运行任意命令与 TUI 程序(vim/htop 等),支持多标签页与断线自动重连(刷新页面/网络抖动后会话保活并恢复现场);新标签默认在当前会话工作目录打开。标签栏「+」菜单还能开 SSH 标签页(ssh2 原生连接,连接簿在设置卡片维护,支持 agent forwarding 与主机指纹 TOFU 钉扎;连接簿条目可配单跳跳板机 ProxyJump),像本地终端一样操作远程主机。设置卡片开启「会话持久化(tmux)」后,新开的本地/SSH 标签默认由 tmux server 托管(宿主重启/断线超时后重开即恢复现场),长任务建议在持久化开启时运行。长驻进程(dev server、watch、交互式程序)用 tty_open 开一个会话跑(或引导用户到终端面板里运行),不要在 bash 工具里挂起等待;用户提到「开个终端 / 在终端里跑 / SSH 到某台机器」时引导其打开该面板。agent 侧配套工具:tty_list 列出活跃终端会话(含 SSH 的 target 与实时 cwd),tty_capture 读取近期输出(默认清洗 ANSI;last:true 拿「上一条命令」的输出+退出码),tty_screen 读取当前可见屏幕(可读懂 vim/htop 等 TUI),tty_expect 用正则等待输出中的就绪信号(如 dev server URL、构建完成;它**先回看还没读过的已到达输出**,命令瞬间跑完也不会白等——超时若只返回一句诊断文案,别当成「命令没执行」),tty_send 发送按键,tunnel_list 列出端口转发隧道状态——操作会实时显示在用户终端里。SFTP 文件传输:面板内可对 SSH 连接簿条目(或 SSH 连接对话框当前填写的信息)打开文件浏览(上传/下载/建目录/重命名/删除),传输期间进度条右侧 ✕ 可取消(半截文件自动清理);agent 配套 sftp_list 列远程目录、sftp_tree 递归看目录结构、sftp_read 读远程文本文件(≤1MB)、sftp_write 写远程文本文件(≤1MB,可追加)、sftp_mkdir 建目录(parents 可逐级补齐)、sftp_rename 重命名/移动、sftp_remove 删除(目录需 recursive),book 参数为连接簿条目名。端口转发:连接簿条目可配本地/远程隧道(如把远程数据库映射到本地端口),宿主自动保活重连,用户提到「转发端口 / 访问远程库」时引导其到终端面板设置卡片配置。推荐流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
216
233
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
217
|
-
function wrapLocalPty(handle) {
|
|
234
|
+
export function wrapLocalPty(handle) {
|
|
218
235
|
let resizeWarned = false;
|
|
236
|
+
// D79:README 承诺「DSH 升级若改内部结构会警告一次并退化为固定尺寸」,但老写法是
|
|
237
|
+
// `handle.terminal?.resize?.(…)` —— `?.` 只在**抛错**时才进 catch,DSH 若把
|
|
238
|
+
// `handle.terminal` 改名 / 移除(而不是让 resize 抛错),这里既不 resize 也不警告,
|
|
239
|
+
// 排查时一点线索都没有(承诺成了空头支票)。两种坏法现在都记一条日志。
|
|
240
|
+
const warnResizeDegraded = (reason) => {
|
|
241
|
+
if (resizeWarned)
|
|
242
|
+
return;
|
|
243
|
+
resizeWarned = true;
|
|
244
|
+
console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + reason);
|
|
245
|
+
};
|
|
219
246
|
return {
|
|
220
247
|
kind: 'local',
|
|
221
248
|
pid: handle.pid,
|
|
@@ -223,15 +250,16 @@ function wrapLocalPty(handle) {
|
|
|
223
250
|
done: handle.done,
|
|
224
251
|
write: (data) => handle.write(data),
|
|
225
252
|
resize: (cols, rows) => {
|
|
253
|
+
const terminal = handle.terminal;
|
|
254
|
+
if (terminal === undefined || typeof terminal.resize !== 'function') {
|
|
255
|
+
warnResizeDegraded('handle.terminal.resize 不存在');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
226
258
|
try {
|
|
227
|
-
|
|
259
|
+
terminal.resize(cols, rows);
|
|
228
260
|
}
|
|
229
261
|
catch (error) {
|
|
230
|
-
|
|
231
|
-
if (!resizeWarned) {
|
|
232
|
-
resizeWarned = true;
|
|
233
|
-
console.warn('[dsh-tty] resize 透传失败(DSH 内部结构可能已变化,退化为固定尺寸): ' + String(error?.message ?? error));
|
|
234
|
-
}
|
|
262
|
+
warnResizeDegraded(String(error?.message ?? error));
|
|
235
263
|
}
|
|
236
264
|
},
|
|
237
265
|
terminate: () => handle.terminate(),
|
|
@@ -522,9 +550,15 @@ const expectCounts = new WeakMap();
|
|
|
522
550
|
* 整数夹紧(0.19.0):ws 帧输入零信任——`Number('abc')=NaN`、`-5`、`1.5`、
|
|
523
551
|
* `1e9` 都不能原样透传给 node-pty 的 ioctl 与 xterm-headless(后者曾在
|
|
524
552
|
* resize 帧路径直接炸出未捕获异常)。非法值回落 fallback,范围内取整。
|
|
553
|
+
*
|
|
554
|
+
* D79 补的一档(0.22.1):`null` / `''` / 布尔 / 对象也**不是数字**,但
|
|
555
|
+
* `Number(null)` = 0、`Number('')` = 0 会通过 isFinite 检查 ⇒ 被夹成下限 2。而客户端
|
|
556
|
+
* 把 NaN 发成 JSON 时正是 `null`(FitAddon 对游离 / 隐藏容器给 NaN,见
|
|
557
|
+
* `client-src/fit-size.js`),于是「根本不该发」的尺寸被翻译成「合法的极小尺寸」——
|
|
558
|
+
* 后台标签的 PTY 就是这么变成 2×2 的。非数值一律与 NaN 同档:回落 fallback。
|
|
525
559
|
*/
|
|
526
560
|
function clampInt(value, fallback, min, max) {
|
|
527
|
-
const n = Number(value);
|
|
561
|
+
const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
528
562
|
if (!Number.isFinite(n))
|
|
529
563
|
return fallback;
|
|
530
564
|
return Math.min(max, Math.max(min, Math.round(n)));
|
|
@@ -1577,8 +1611,13 @@ export class TtyServer {
|
|
|
1577
1611
|
* 共享一次 df/netstat);
|
|
1578
1612
|
* - SSH:远端 sh + awk 常驻循环,每秒一行 JSON 走**非 PTY** exec channel;
|
|
1579
1613
|
* 速率类由远端算好,宿主只解析 + 清洗。
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1614
|
+
* 失败时静默停表并置 statsFailed:前端靠「无数据」隐藏状态条,PTY 数据路径与
|
|
1615
|
+
* 终端体验完全不受影响。
|
|
1616
|
+
*
|
|
1617
|
+
* 但失败位**不再粘死整个会话**(D83):原先置位后永不复位,一次瞬态故障
|
|
1618
|
+
* (sshd MaxSessions 拒绝并发 channel、单通道 ECONNRESET)就让状态条与 agent
|
|
1619
|
+
* tty_stats 在会话余生里彻底没有数据,而主 PTY 通道其实是健康的。现在按指数
|
|
1620
|
+
* 退避自动重挂(出过帧即计数归零),既保住「别每秒重启」的本意,又能自愈。
|
|
1582
1621
|
*/
|
|
1583
1622
|
startStats(session) {
|
|
1584
1623
|
// D77:只读保留态没有进程可采(本地会取到宿主、远端 channel 早断了)——直接不起表
|
|
@@ -1619,7 +1658,9 @@ export class TtyServer {
|
|
|
1619
1658
|
}
|
|
1620
1659
|
const statsExec = session.handle.statsExec;
|
|
1621
1660
|
if (statsExec === undefined) {
|
|
1661
|
+
// 句柄缺失(连接已断等)同样退避重挂,而不是永久放弃(D83)
|
|
1622
1662
|
session.statsFailed = true;
|
|
1663
|
+
this.scheduleStatsRetry(session);
|
|
1623
1664
|
return;
|
|
1624
1665
|
}
|
|
1625
1666
|
let stopped = false;
|
|
@@ -1647,6 +1688,7 @@ export class TtyServer {
|
|
|
1647
1688
|
if (frame === null)
|
|
1648
1689
|
return;
|
|
1649
1690
|
sawFrame = true;
|
|
1691
|
+
session.statsFailures = 0; // 出过帧:退避计数归零,下次失败从头退避
|
|
1650
1692
|
if (hasStatsData(frame))
|
|
1651
1693
|
this.sendStats(session, frame);
|
|
1652
1694
|
}, () => {
|
|
@@ -1656,10 +1698,12 @@ export class TtyServer {
|
|
|
1656
1698
|
attempt(buildWindowsStatsCommand(), true);
|
|
1657
1699
|
return;
|
|
1658
1700
|
}
|
|
1659
|
-
// 读过帧 = 远端采集进程自己停了;一帧未读 =
|
|
1701
|
+
// 读过帧 = 远端采集进程自己停了;一帧未读 = 这一跳没起来——都停表。
|
|
1702
|
+
// D83:置失败位让前端先隐藏状态条,但排一次退避重挂,恢复后自愈
|
|
1660
1703
|
session.statsFailed = true;
|
|
1661
1704
|
session.stats = null;
|
|
1662
1705
|
collector.stop();
|
|
1706
|
+
this.scheduleStatsRetry(session);
|
|
1663
1707
|
});
|
|
1664
1708
|
};
|
|
1665
1709
|
// 先登记再起采集:同步失败(conn.exec 直接抛错)也走同一套收尾
|
|
@@ -1668,6 +1712,29 @@ export class TtyServer {
|
|
|
1668
1712
|
// 双跳同步失败时 collector.stop() 已把当时在手的句柄停掉;conn.exec 直接抛错的
|
|
1669
1713
|
// 那一跳压根没建 channel,句柄是惰性的,不需要额外收尾。
|
|
1670
1714
|
}
|
|
1715
|
+
/**
|
|
1716
|
+
* 采集失败后的退避重挂(D83)。
|
|
1717
|
+
*
|
|
1718
|
+
* 退避而不是立刻重试,是为了保住原先「粘性失败位」想解决的问题——远端平台压根
|
|
1719
|
+
* 没有采集源(macOS/BSD:既无 /proc 也无 PowerShell)时不能每秒重启一个必然失败
|
|
1720
|
+
* 的 channel。指数退避 + 上限把这种「稳态失败」压到几分钟一次,同时让瞬态故障
|
|
1721
|
+
* 在恢复后自动回到有数据状态(出过帧就归零)。
|
|
1722
|
+
*/
|
|
1723
|
+
scheduleStatsRetry(session) {
|
|
1724
|
+
if (session.closed || session.exited !== null || !this.statsOn)
|
|
1725
|
+
return;
|
|
1726
|
+
session.statsFailures += 1;
|
|
1727
|
+
const delay = Math.min(STATS_RETRY_BASE_MS * 2 ** (session.statsFailures - 1), STATS_RETRY_MAX_MS);
|
|
1728
|
+
session.statsRetryAt = Date.now() + delay;
|
|
1729
|
+
const timer = setTimeout(() => {
|
|
1730
|
+
session.statsRetryAt = 0;
|
|
1731
|
+
if (session.closed || session.exited !== null || !this.statsOn)
|
|
1732
|
+
return;
|
|
1733
|
+
session.statsFailed = false; // 清位后 startStats 的门禁才放行
|
|
1734
|
+
this.startStats(session);
|
|
1735
|
+
}, delay);
|
|
1736
|
+
timer.unref?.();
|
|
1737
|
+
}
|
|
1671
1738
|
/** 停表(幂等):订阅清零 / 会话结束 / 插件禁用 / 配置关闭都走它。 */
|
|
1672
1739
|
stopStats(session) {
|
|
1673
1740
|
const collector = session.stats;
|
|
@@ -1959,7 +2026,7 @@ export class TtyServer {
|
|
|
1959
2026
|
}
|
|
1960
2027
|
resolve(result);
|
|
1961
2028
|
};
|
|
1962
|
-
const timer = setTimeout(() => { finish({ available: false, reason:
|
|
2029
|
+
const timer = setTimeout(() => { finish({ available: false, reason: `远端采集超时(${String(STATS_ONESHOT_TIMEOUT_MS / 1000)}s)` }); }, STATS_ONESHOT_TIMEOUT_MS);
|
|
1963
2030
|
timer.unref?.();
|
|
1964
2031
|
try {
|
|
1965
2032
|
handle = statsExec(buildRemoteStatsCommand(), (line) => {
|
|
@@ -2020,6 +2087,8 @@ export class TtyServer {
|
|
|
2020
2087
|
: buildShellSpawn(this.options.shell, this.options.term, this.options.colorTerm, this.options.shellIntegration);
|
|
2021
2088
|
let tmuxName = null;
|
|
2022
2089
|
let degraded = false;
|
|
2090
|
+
/** 降级原因:探测**超时**(机器忙)还是确定没装——提示文案必须分开(D80)。 */
|
|
2091
|
+
let degradedInconclusive = false;
|
|
2023
2092
|
if (wantsPersist) {
|
|
2024
2093
|
const probe = await probeTmux();
|
|
2025
2094
|
if (probe.available) {
|
|
@@ -2028,7 +2097,8 @@ export class TtyServer {
|
|
|
2028
2097
|
spawnPlan = buildTmuxSpawnPlan({ shell: this.options.shell, term: this.options.term, colorTerm: this.options.colorTerm, tmuxName });
|
|
2029
2098
|
}
|
|
2030
2099
|
else {
|
|
2031
|
-
degraded = true; //
|
|
2100
|
+
degraded = true; // 降级普通会话,由调用方给灰字提示;原因(超时/没装)一并带出去
|
|
2101
|
+
degradedInconclusive = probe.inconclusive === true;
|
|
2032
2102
|
}
|
|
2033
2103
|
}
|
|
2034
2104
|
const create = (async () => {
|
|
@@ -2076,6 +2146,8 @@ export class TtyServer {
|
|
|
2076
2146
|
lastStats: null,
|
|
2077
2147
|
stats: null,
|
|
2078
2148
|
statsFailed: false,
|
|
2149
|
+
statsRetryAt: 0,
|
|
2150
|
+
statsFailures: 0,
|
|
2079
2151
|
};
|
|
2080
2152
|
// 绑定:有客户端才绑(agent 路径 client === null → 保持空表 = 无客户端会话)。
|
|
2081
2153
|
// 空表但 owner:'agent',故不会被孤儿回收器当孤儿收掉。
|
|
@@ -2104,7 +2176,7 @@ export class TtyServer {
|
|
|
2104
2176
|
const session = await create;
|
|
2105
2177
|
this.attachOutput(session);
|
|
2106
2178
|
this.watchDone(session, local);
|
|
2107
|
-
return { session, wantsPersist, degraded };
|
|
2179
|
+
return { session, wantsPersist, degraded, degradedInconclusive };
|
|
2108
2180
|
}
|
|
2109
2181
|
/**
|
|
2110
2182
|
* 立即终止会话:同步退役 + 顶层 shell 直接 SIGKILL,让 done/exit 帧立刻可发;
|
|
@@ -2255,7 +2327,12 @@ export class TtyServer {
|
|
|
2255
2327
|
const next = created.session;
|
|
2256
2328
|
send(ws, { t: 'ready', sid, pid: next.handle.pid, kind: 'local', ...(next.tmuxName !== null ? { persist: true } : {}) });
|
|
2257
2329
|
if (created.wantsPersist && created.degraded) {
|
|
2258
|
-
|
|
2330
|
+
// 超时与「确定没装」的提示分开(D80):前者让用户重开一次标签即可(那时
|
|
2331
|
+
// 机器已经不忙了),后者才需要去装 tmux。原先一律写「未检测到 tmux」,
|
|
2332
|
+
// 把「机器忙」误报成「没装」,排障方向被带偏。
|
|
2333
|
+
const notice = created.degradedInconclusive
|
|
2334
|
+
? '\x1b[2m[dsh-tty] tmux 探测超时(机器较忙),本标签以普通会话运行;重开一个标签页即可再试持久化\x1b[0m\r\n'
|
|
2335
|
+
: '\x1b[2m[dsh-tty] 未检测到 tmux,本标签以普通会话运行;安装 tmux 后持久化标签可跨宿主重启恢复现场\x1b[0m\r\n';
|
|
2259
2336
|
appendOutput(next, notice);
|
|
2260
2337
|
send(ws, { t: 'data', sid, d: notice });
|
|
2261
2338
|
}
|
|
@@ -2359,6 +2436,8 @@ export class TtyServer {
|
|
|
2359
2436
|
lastStats: null,
|
|
2360
2437
|
stats: null,
|
|
2361
2438
|
statsFailed: false,
|
|
2439
|
+
statsRetryAt: 0,
|
|
2440
|
+
statsFailures: 0,
|
|
2362
2441
|
};
|
|
2363
2442
|
local.set(sid, next);
|
|
2364
2443
|
this.sessions.add(next);
|
|
@@ -2414,6 +2493,9 @@ export class TtyServer {
|
|
|
2414
2493
|
}
|
|
2415
2494
|
}
|
|
2416
2495
|
else if (msg.t === 'resize') {
|
|
2496
|
+
// D79:`msg.cols/rows` 缺失或非数值(客户端把 NaN 序列化成 `null` 就是这种)时
|
|
2497
|
+
// clampInt 回落 80×24,**不是**夹到下限 2——客户端的门槛在 client-src/fit-size.js,
|
|
2498
|
+
// 这里是防御纵深:旧版本客户端 / 第三方客户端发来的垃圾值不该把 PTY 压成 2×2。
|
|
2417
2499
|
const resolved = this.resolveSid(ws, msg, local);
|
|
2418
2500
|
if (resolved === undefined || 'unknown' in resolved)
|
|
2419
2501
|
return;
|
|
@@ -2473,8 +2555,13 @@ export class TtyServer {
|
|
|
2473
2555
|
// tmux 托管、本机清单看不到,从 settings 留存读取)——客户端用它确认
|
|
2474
2556
|
// localStorage 里的持久标签规格是否仍可恢复(新窗口/新浏览器)
|
|
2475
2557
|
const localTmux = await listTmuxSessions();
|
|
2476
|
-
|
|
2477
|
-
|
|
2558
|
+
// 本机清单探不明白(超时)时**整个字段缺席**(D81):只给 SSH 侧的名字会被
|
|
2559
|
+
// 读成「本机没有持久会话」——正是「失败伪装成确定答案」要避免的
|
|
2560
|
+
send(ws, {
|
|
2561
|
+
t: 'sessions',
|
|
2562
|
+
list: this.sessions.listForAttach(),
|
|
2563
|
+
...(localTmux !== undefined ? { tmux: [...new Set([...localTmux, ...this.options.persistSessions])] } : {}),
|
|
2564
|
+
});
|
|
2478
2565
|
}
|
|
2479
2566
|
else if (msg.t === 'attach') {
|
|
2480
2567
|
const raw = msg.sid;
|
|
@@ -4141,7 +4228,7 @@ const plugin = definePlugin({
|
|
|
4141
4228
|
})));
|
|
4142
4229
|
activeDisposers.push(tools.register(defineTool({
|
|
4143
4230
|
name: 'tty_open',
|
|
4144
|
-
description: '开一个新的终端会话(本地 shell,或 `command` 直接跑一条命令,如 dev server)。会话出现在用户的终端面板里、用户可见可接管,长驻进程与 watch 类任务应该用它(不要在 bash 工具里挂起等待)。开了之后用 tty_expect 等就绪信号、tty_capture{last:true} 拿结果;用完用 tty_close 关闭。`command` 按
|
|
4231
|
+
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 缺省为插件配置的工作目录。',
|
|
4145
4232
|
parameters: {
|
|
4146
4233
|
cwd: { type: 'string', description: '工作目录(必须是已存在的绝对路径);缺省用插件配置的 cwd' },
|
|
4147
4234
|
command: { type: 'string', description: '直接执行的命令(非交互):按**整段 shell 代码**执行,`cd x && cmd`、`a; b`、管道、多行脚本都可以(D78);给出时不做 tmux 持久化。省略或全空白 = 交互式 shell' },
|
|
@@ -4160,7 +4247,7 @@ const plugin = definePlugin({
|
|
|
4160
4247
|
},
|
|
4161
4248
|
render: (_args, value) => {
|
|
4162
4249
|
const v = value;
|
|
4163
|
-
return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect
|
|
4250
|
+
return [{ type: 'text', text: `已开终端会话 sid=${v.sid ?? '?'}${v.persist === true ? '(tmux 持久)' : ''}。它在用户的终端面板里可见;下一步可用 tty_send 执行命令、tty_expect 等就绪信号。\n${commandShellHint(live.shell)}` }];
|
|
4164
4251
|
},
|
|
4165
4252
|
},
|
|
4166
4253
|
async execute(args) {
|
|
@@ -4176,7 +4263,7 @@ const plugin = definePlugin({
|
|
|
4176
4263
|
})));
|
|
4177
4264
|
activeDisposers.push(tools.register(defineTool({
|
|
4178
4265
|
name: 'tty_close',
|
|
4179
|
-
description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留
|
|
4266
|
+
description: '关闭一个由 tty_open 开的终端会话(结束其中的进程)。**只能关 agent 自己开的会话**:用户在面板里开的标签会被拒绝,请让用户自己在面板里关,不要越权结束用户正在用的终端。对**进程已退出但仍只读保留着**的会话同样可用——那就是它的释放入口(把屏与缓冲还回去)。只读保留默认留到显式关闭(宿主重启也会清空),不按时间释放;同时最多留 16 条,超出按最旧淘汰。',
|
|
4180
4267
|
parameters: {
|
|
4181
4268
|
sid: { type: 'string', required: true, description: '会话 id(tty_open 或 tty_list 提供)' },
|
|
4182
4269
|
},
|
|
@@ -5090,16 +5177,19 @@ const plugin = definePlugin({
|
|
|
5090
5177
|
order: 150,
|
|
5091
5178
|
text: () => {
|
|
5092
5179
|
const list = sessions.list();
|
|
5180
|
+
// D78 补:本地命令的语法随宿主 shell 变(POSIX / cmd / PowerShell),
|
|
5181
|
+
// 而「Shell 路径」是热改的配置——所以这行每轮现算,不冻在工具描述里。
|
|
5182
|
+
const shellLine = commandShellHint(live.shell);
|
|
5093
5183
|
if (list.length === 0)
|
|
5094
|
-
return
|
|
5184
|
+
return `当前没有终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。\n${shellLine}`;
|
|
5095
5185
|
// D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
|
|
5096
5186
|
// 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
|
|
5097
5187
|
// 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
|
|
5098
|
-
const
|
|
5188
|
+
const alive = list.filter((s) => s.exited !== true);
|
|
5099
5189
|
const gone = list.filter((s) => s.exited === true);
|
|
5100
|
-
const head =
|
|
5190
|
+
const head = alive.length === 0
|
|
5101
5191
|
? '当前没有活着的终端面板会话。'
|
|
5102
|
-
: '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' +
|
|
5192
|
+
: '当前活跃的终端面板会话(可用 tty_capture / tty_screen / tty_expect / tty_send 操作,用 tty_open / tty_close 开关,sid 如下):\n' + alive.map((s) => {
|
|
5103
5193
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
5104
5194
|
const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
|
|
5105
5195
|
return `- sid=${s.sid} [${s.kind}]${owner}${s.persist === true ? ' [tmux 持久]' : ''} ${where} (最后活动 ${new Date(s.lastOutputAt).toLocaleTimeString()})`;
|
|
@@ -5110,7 +5200,7 @@ const plugin = definePlugin({
|
|
|
5110
5200
|
const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
|
|
5111
5201
|
return `sid=${s.sid} (${how})`;
|
|
5112
5202
|
}).join('、');
|
|
5113
|
-
return head + tail;
|
|
5203
|
+
return head + tail + '\n' + shellLine;
|
|
5114
5204
|
},
|
|
5115
5205
|
});
|
|
5116
5206
|
sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|