@hyzyn/dsh-tty 0.27.0 → 0.30.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 +79 -7
- package/README.md +67 -8
- package/client.js +240 -61
- package/lib/index.d.ts +82 -3
- package/lib/index.js +224 -22
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/scripts/preview/harness.js +688 -2
- package/scripts/preview/mock-host.js +5 -0
- package/scripts/preview.mjs +12 -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, attachTierGate, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGrantVia, capabilityGranted, capabilityPaths, createElevationManager, } from '@hyzyn/dsh-kit';
|
|
19
19
|
import { probeSsh } from './probe.js';
|
|
20
|
-
import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath } from './shell-integration.js';
|
|
20
|
+
import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath, tmuxSocketName } from './shell-integration.js';
|
|
21
21
|
import { KEY_VOCABULARY, resolveKeys } from './keys.js';
|
|
22
22
|
import { parseSshConfigDetailed } from './ssh-config.js';
|
|
23
23
|
import { parseKnownHostsDetailed } from './known-hosts.js';
|
|
@@ -252,6 +252,30 @@ const BUFFER_CAP = 256 * 1024;
|
|
|
252
252
|
const TERM_RE = /^[A-Za-z0-9_.+-]+$/;
|
|
253
253
|
/** 孤儿会话回收器的扫描间隔。 */
|
|
254
254
|
const REAPER_INTERVAL_MS = 10_000;
|
|
255
|
+
/** 进程级挂载序号在 `globalThis` 上的键(见 `nextMountSerial`)。 */
|
|
256
|
+
const MOUNT_SERIAL_KEY = '__dshTtyMountSerial';
|
|
257
|
+
/**
|
|
258
|
+
* 本进程内第几次挂载(0.30.0 可观测性)。
|
|
259
|
+
*
|
|
260
|
+
* 为什么放 `globalThis` 而不是模块态:**模块态在 HMR 重挂时会被重新求值**——序号从 1 重来,
|
|
261
|
+
* 而这里要回答的恰恰是「同一个 pid 里这是第几次挂载」,模块态答不了这个问题。宿主重启会清零
|
|
262
|
+
* (换了进程),那正是我们要的语义。
|
|
263
|
+
*/
|
|
264
|
+
function nextMountSerial() {
|
|
265
|
+
const bag = globalThis;
|
|
266
|
+
const next = (bag[MOUNT_SERIAL_KEY] ?? 0) + 1;
|
|
267
|
+
bag[MOUNT_SERIAL_KEY] = next;
|
|
268
|
+
return next;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* 孤儿会话被回收时的留痕文案(导出仅供单测)。
|
|
272
|
+
*
|
|
273
|
+
* 报数与被回收的 sid **同源**(`SessionManager.reapOrphans` 的返回值),不在调用方另数一遍;
|
|
274
|
+
* 断线保活的秒数一起写进去——读日志的人不必回头翻配置。
|
|
275
|
+
*/
|
|
276
|
+
export function reapedOrphansMessage(sids, graceSec) {
|
|
277
|
+
return `[dsh-tty] 断线保活已过(${String(graceSec)}s):回收 ${String(sids.length)} 个孤儿会话 ${sids.join(', ')} —— 它们里面跑的程序一并结束;想跨断线留住现场请用 tmux 持久标签,或在设置卡片把「断线保活」调大`;
|
|
278
|
+
}
|
|
255
279
|
/** 服务器状态条的采集/推送间隔(mvp 固定 1s,不做配置项)。 */
|
|
256
280
|
const STATS_INTERVAL_MS = 1000;
|
|
257
281
|
/**
|
|
@@ -271,7 +295,23 @@ const STATS_RETRY_MAX_MS = 300_000;
|
|
|
271
295
|
* 调用等得起,而写死的小值会把慢首帧误报成「采不到」。
|
|
272
296
|
*/
|
|
273
297
|
const STATS_ONESHOT_TIMEOUT_MS = 15_000;
|
|
274
|
-
|
|
298
|
+
/**
|
|
299
|
+
* 注入 systemPrompt 的常驻指引。
|
|
300
|
+
*
|
|
301
|
+
* **先写「什么时候别用它」**(决策句在最前):这段文本每一轮都在上下文里,而模型
|
|
302
|
+
* 真正的失败模式是「本来 bash 一句就够,却去开一个终端会话」——把判据放最前比放末尾
|
|
303
|
+
* 有用。各工具的用法(`running` 三态、`last:true`、具名 `keys`、回溯语义…)**只写在
|
|
304
|
+
* 各自的工具描述里**,这里不再复述一遍:同一件事写两处,既白占每轮上下文,又会随
|
|
305
|
+
* 改动漂成两份互相矛盾的真相(本文件历史上这两处就重复过)。
|
|
306
|
+
*
|
|
307
|
+
* 这里只留工具描述里**没有**的东西:面板侧能力(SSH / SFTP / 隧道 / tmux 持久化在哪配)
|
|
308
|
+
* 与引导用户的话术。
|
|
309
|
+
*/
|
|
310
|
+
const TTY_GUIDANCE = '**先判断要不要用终端**:只有「需要跨调用保留状态(REPL / 调试器)、要跑长驻或交互式进程、或用户要看着这条命令跑」才用 tty_open / tty_run;一次跑完的有界命令直接用 bash 工具,别在 bash 里挂起等待。用完的会话记得 tty_close——agent 开的会话不随连接回收,攒着会占会话名额与面板标签。'
|
|
311
|
+
+ '面板(Web GUI 侧边栏「终端」)是真 PTY:vim / htop 等 TUI、多标签、断线重连都能跑,新标签默认在当前会话工作目录打开。agent 开的会话**就出现在用户面板里、用户可接管**,所以别开隐形会话。'
|
|
312
|
+
+ '标签栏「+」还能开 SSH 标签(ssh2 原生连接;连接簿、agent forwarding、主机指纹 TOFU、单跳跳板机都在设置卡片维护);面板内另有 SFTP 文件浏览(上传/下载/建目录/重命名/删除),以及由连接簿条目配置的端口转发隧道;设置卡片开「会话持久化(tmux)」后新标签由 tmux 托管,能跨宿主重启恢复,长任务建议开着它跑。'
|
|
313
|
+
+ '用户提到「开个终端 / 在终端里跑 / SSH 到某台机器 / 传文件 / 转发端口」时,引导他打开这个面板。'
|
|
314
|
+
+ '典型流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
275
315
|
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。 */
|
|
276
316
|
export function wrapLocalPty(handle) {
|
|
277
317
|
let resizeWarned = false;
|
|
@@ -358,6 +398,30 @@ function describeExit(exited) {
|
|
|
358
398
|
return `signal=${exited.signal}`;
|
|
359
399
|
return exited.code === null ? '退出码未知' : `exitCode=${String(exited.code)}`;
|
|
360
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* 「另有 N 条标签被用户设为对 agent 不可见」那句话(0.30.0)。
|
|
403
|
+
*
|
|
404
|
+
* 两处共用这一份:`tty_list` 的渲染与每轮 systemPrompt 的会话块。同一件事写两遍必然漂,
|
|
405
|
+
* 而漂出来的结果是「工具里说 3 条、prompt 里说 2 条」这种对不上的话术。
|
|
406
|
+
*
|
|
407
|
+
* `count <= 0` 返回空串(调用方直接拼接,不必自己判)。
|
|
408
|
+
*/
|
|
409
|
+
export function hiddenSessionsNote(count) {
|
|
410
|
+
if (count <= 0)
|
|
411
|
+
return '';
|
|
412
|
+
return `\n另有 ${String(count)} 条标签被用户设为「对 agent 不可见」:读不到、发不了命令,也不要拿其它 sid 去试——需要它们对 AI 开放时请用户在面板里取消隐藏。`;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* 隐藏会话的拒绝文案(0.30.0,agent 侧工具文案只此一处)。
|
|
416
|
+
*
|
|
417
|
+
* 为什么明说「被隐藏」而不是伪装成「会话不存在」:模型拿到莫名的不存在会换个 sid 重试、
|
|
418
|
+
* 或以为会话崩了去重开一条——**明确的拒绝才能让它停下来并转告用户**。要藏的从来不是
|
|
419
|
+
* 「有这条会话」,而是它的内容与在干什么(那部分由 `listForAgent` 整条不列出负责)。
|
|
420
|
+
*/
|
|
421
|
+
function hiddenRefusal(sid) {
|
|
422
|
+
return new Error(`会话 ${sid} 被用户设为「对 agent 不可见」:它不对 agent 开放(读输出、发命令、取指标都不行)。` +
|
|
423
|
+
'请让用户在终端面板里对该标签取消隐藏,或由用户自己操作它;不要用其它 sid 试探——那不是同一个终端。');
|
|
424
|
+
}
|
|
361
425
|
/**
|
|
362
426
|
* 只读保留的剩余毫秒(D77);`null` = **不按时间释放**(策略为 ∞)或不是保留态。
|
|
363
427
|
*
|
|
@@ -1740,14 +1804,45 @@ export class SessionManager {
|
|
|
1740
1804
|
lastOutputAt: session.lastOutputAt,
|
|
1741
1805
|
owner: session.owner,
|
|
1742
1806
|
...runFields,
|
|
1807
|
+
...(session.hiddenFromAgent ? { hidden: true } : {}),
|
|
1743
1808
|
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
1744
1809
|
...exitInfo,
|
|
1745
1810
|
};
|
|
1746
1811
|
return session.handle.pid === null ? base : { ...base, pid: session.handle.pid };
|
|
1747
1812
|
}
|
|
1748
|
-
/**
|
|
1749
|
-
|
|
1750
|
-
|
|
1813
|
+
/**
|
|
1814
|
+
* agent 侧用的只读快照(`tty_list` 工具与每轮 systemPrompt 的会话块)。
|
|
1815
|
+
*
|
|
1816
|
+
* **用户标了「对 agent 不可见」的会话整条不在**(0.30.0):不能只抹掉输出——会话的
|
|
1817
|
+
* 存在、cwd、有没有命令在跑,本身就是要藏的东西(它泄漏用户在干什么)。面板侧走
|
|
1818
|
+
* `listForAttach()`,那条路照旧给全部会话。
|
|
1819
|
+
*/
|
|
1820
|
+
listForAgent() {
|
|
1821
|
+
return [...this.sessions.values()]
|
|
1822
|
+
.filter((session) => !session.hiddenFromAgent)
|
|
1823
|
+
.map((session) => this.snapshotOf(session));
|
|
1824
|
+
}
|
|
1825
|
+
/** agent 侧按 sid 取会话(0.30.0):隐藏会话与「不存在」**同样拒绝**,见 hiddenRefusal。 */
|
|
1826
|
+
agentView(sid) {
|
|
1827
|
+
const session = this.sessions.get(sid);
|
|
1828
|
+
if (session !== undefined && session.hiddenFromAgent)
|
|
1829
|
+
throw hiddenRefusal(sid);
|
|
1830
|
+
return session;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* 被用户隐藏的会话条数(0.30.0)。
|
|
1834
|
+
*
|
|
1835
|
+
* 存在的意义是**别让模型把话说过头**:一条都不列之后,`listForAgent()` 为空会被读成
|
|
1836
|
+
* 「用户没开终端」,于是它可能回一句「当前没有终端面板会话」——而用户正盯着三个标签。
|
|
1837
|
+
* 只报条数、不报 cwd 与活动(要藏的正是后者)。这一位信息量极低(人人都有终端),
|
|
1838
|
+
* 换来的是模型不再因为「看不见」而误导用户。
|
|
1839
|
+
*/
|
|
1840
|
+
hiddenCount() {
|
|
1841
|
+
let count = 0;
|
|
1842
|
+
for (const session of this.sessions.values())
|
|
1843
|
+
if (session.hiddenFromAgent)
|
|
1844
|
+
count += 1;
|
|
1845
|
+
return count;
|
|
1751
1846
|
}
|
|
1752
1847
|
/** sessions 帧用:额外带 attachable(孤儿且未关闭的会话可被新连接 attach)。 */
|
|
1753
1848
|
listForAttach() {
|
|
@@ -1809,6 +1904,8 @@ export class SessionManager {
|
|
|
1809
1904
|
*/
|
|
1810
1905
|
async reapOrphans(graceMs) {
|
|
1811
1906
|
const now = Date.now();
|
|
1907
|
+
/** 本轮真的被回收的会话——**返回值就是日志报数的来源**,别在调用方另数一遍。 */
|
|
1908
|
+
const reaped = [];
|
|
1812
1909
|
for (const session of [...this.sessions.values()]) {
|
|
1813
1910
|
if (session.owner === 'agent')
|
|
1814
1911
|
continue;
|
|
@@ -1820,9 +1917,11 @@ export class SessionManager {
|
|
|
1820
1917
|
if (session.orphanedAt === null)
|
|
1821
1918
|
continue;
|
|
1822
1919
|
if (graceMs <= 0 || now - session.orphanedAt >= graceMs) {
|
|
1920
|
+
reaped.push(session);
|
|
1823
1921
|
void this.destroy(session); // 后台收尾:terminate 最慢可达 ~20s,不阻塞回收器
|
|
1824
1922
|
}
|
|
1825
1923
|
}
|
|
1924
|
+
return reaped;
|
|
1826
1925
|
}
|
|
1827
1926
|
/**
|
|
1828
1927
|
* 只读保留到点退役(D77;回收器每轮调用):超过保留期的会话出表 + 释放屏。
|
|
@@ -2156,6 +2255,17 @@ export class TtyServer {
|
|
|
2156
2255
|
this.wss.emit('connection', ws, req);
|
|
2157
2256
|
});
|
|
2158
2257
|
}
|
|
2258
|
+
/**
|
|
2259
|
+
* 会话生命周期留痕(0.30.0):一行一件事,落进宿主的 startup 日志
|
|
2260
|
+
* (`~/.dsh/logs/startup-*.log` —— `ctx.logger` 的输出实测会进那里,`console.log` 不会)。
|
|
2261
|
+
*
|
|
2262
|
+
* 加这一组的起因见 ROADMAP 的 0.30.0 那节(会话生命周期留痕):一次「面板标签自己消失」的排查里,宿主侧
|
|
2263
|
+
* **一条生命周期日志都没有**,于是「插件被重挂」与「孤儿被回收」这两种解释只能靠时间吻合去猜。
|
|
2264
|
+
* 所以挂载/卸载、面板连接/断开、会话创建/结束、孤儿回收各留一行——宁可多几句噪音。
|
|
2265
|
+
*/
|
|
2266
|
+
lifecycle(message) {
|
|
2267
|
+
this.ctx.logger.info(`[dsh-tty] ${message}`);
|
|
2268
|
+
}
|
|
2159
2269
|
onConnection(ws) {
|
|
2160
2270
|
/** 本连接的上下文:id 参与跨连接绑定键(D07);open 供在途异步路径判「连接已死」(D06)。 */
|
|
2161
2271
|
const conn = { id: randomUUID(), open: true };
|
|
@@ -2163,6 +2273,7 @@ export class TtyServer {
|
|
|
2163
2273
|
const local = new Map();
|
|
2164
2274
|
this.panels.add(ws);
|
|
2165
2275
|
ws.on('close', () => { this.panels.delete(ws); });
|
|
2276
|
+
this.lifecycle(`面板连接建立(当前 ${String(this.panels.size)} 个连接)`);
|
|
2166
2277
|
const cleanupAll = async () => {
|
|
2167
2278
|
const all = [...local.entries()];
|
|
2168
2279
|
local.clear();
|
|
@@ -2200,7 +2311,13 @@ export class TtyServer {
|
|
|
2200
2311
|
});
|
|
2201
2312
|
ws.on('close', () => {
|
|
2202
2313
|
conn.open = false;
|
|
2314
|
+
const owned = local.size;
|
|
2203
2315
|
void cleanupAll();
|
|
2316
|
+
/*
|
|
2317
|
+
* 断开这一行是排查「标签自己没了」的第二个锚点(第一个是挂载/卸载那一对):宽限秒数
|
|
2318
|
+
* 与「几个会话转孤儿」都写进去——断线超过保活期就是回收器动手,两行能对上账。
|
|
2319
|
+
*/
|
|
2320
|
+
this.lifecycle(`面板连接断开(本连接 ${String(owned)} 个会话 → 转孤儿,${String(Math.round(this.options.reconnectGraceMs / 1000))}s 内没重连就回收;还剩 ${String(this.panels.size)} 个连接)`);
|
|
2204
2321
|
});
|
|
2205
2322
|
ws.on('error', (error) => {
|
|
2206
2323
|
this.ctx.logger.warn('[dsh-tty] ws error: ' + error.message);
|
|
@@ -2265,10 +2382,31 @@ export class TtyServer {
|
|
|
2265
2382
|
kind: session.kind,
|
|
2266
2383
|
target: session.target !== '' ? session.target : undefined,
|
|
2267
2384
|
...(session.tmuxName !== null ? { persist: true } : {}),
|
|
2385
|
+
...(session.hiddenFromAgent ? { hidden: true } : {}),
|
|
2268
2386
|
});
|
|
2269
2387
|
if (session.tmuxName !== null)
|
|
2270
2388
|
void session.handle.tmuxRefresh?.();
|
|
2271
2389
|
}
|
|
2390
|
+
/**
|
|
2391
|
+
* 设置「对 agent 不可见」(0.30.0):`spawn` / `ssh` 帧(含跨窗口重绑定)与运行期的
|
|
2392
|
+
* `hidden` 帧**共用这一处**,避免两条路各写一遍判定。
|
|
2393
|
+
*
|
|
2394
|
+
* **只对用户标签生效**(`owner === 'user'`):隐藏是用户对「我的标签」的处置;agent
|
|
2395
|
+
* 若能把自己开的会话藏起来,等于把它的行为从用户眼前抹掉(D06「隐形会话」的反面)。
|
|
2396
|
+
* 判据放在这个唯一写入口而不是各调用点——重绑定那条路(同 persistName 撞上别的
|
|
2397
|
+
* 窗口/agent 的会话)也一并兜住。
|
|
2398
|
+
*
|
|
2399
|
+
* 值没变时不打日志:重连 / 重绑定会带同一个值反复走这里,每次都记一行会把
|
|
2400
|
+
* lifecycle 日志淹掉(那条日志的用途是「查会话为什么消失」)。
|
|
2401
|
+
*/
|
|
2402
|
+
setHiddenFromAgent(session, hidden) {
|
|
2403
|
+
if (hidden && session.owner !== 'user')
|
|
2404
|
+
return;
|
|
2405
|
+
if (session.hiddenFromAgent === hidden)
|
|
2406
|
+
return;
|
|
2407
|
+
session.hiddenFromAgent = hidden;
|
|
2408
|
+
this.lifecycle(`会话可见性:sid=${session.id} kind=${session.kind} hiddenFromAgent=${String(hidden)}(${hidden ? 'agent 侧看不到这条会话' : '恢复对 agent 开放'})`);
|
|
2409
|
+
}
|
|
2272
2410
|
/**
|
|
2273
2411
|
* agent 开一个本地终端(tty_open 的实现)。
|
|
2274
2412
|
*
|
|
@@ -2308,6 +2446,7 @@ export class TtyServer {
|
|
|
2308
2446
|
client: null, // agent 路径:无客户端
|
|
2309
2447
|
local: new Map(),
|
|
2310
2448
|
owner: 'agent',
|
|
2449
|
+
hidden: false, // agent 自己开的会话没有隐藏自己的理由
|
|
2311
2450
|
});
|
|
2312
2451
|
// D72:agent 自己开的会话从出生起「什么都没读过」(水位线落在 seq 0)——
|
|
2313
2452
|
// 包括 `command` 型会话在第一次 expect 之前打印的启动输出。用户开的标签
|
|
@@ -2440,7 +2579,7 @@ export class TtyServer {
|
|
|
2440
2579
|
* `client` 为 null 时创建无客户端的会话(agent 路径)。
|
|
2441
2580
|
*/
|
|
2442
2581
|
async createLocalSession(input) {
|
|
2443
|
-
const { sid, cols, rows, cwd, command, persistName, client, local, owner } = input;
|
|
2582
|
+
const { sid, cols, rows, cwd, command, persistName, client, local, owner, hidden } = input;
|
|
2444
2583
|
const subprocess = this.ctx.get('subprocess');
|
|
2445
2584
|
if (subprocess === undefined)
|
|
2446
2585
|
throw new Error('subprocess 服务不可用');
|
|
@@ -2485,6 +2624,7 @@ export class TtyServer {
|
|
|
2485
2624
|
exited: null,
|
|
2486
2625
|
paused: false,
|
|
2487
2626
|
owner,
|
|
2627
|
+
hiddenFromAgent: hidden,
|
|
2488
2628
|
cwd,
|
|
2489
2629
|
kind: 'local',
|
|
2490
2630
|
target: '',
|
|
@@ -2520,6 +2660,7 @@ export class TtyServer {
|
|
|
2520
2660
|
next.clients.set(client.connId + ':' + sid, { ws: client.ws, sid });
|
|
2521
2661
|
local.set(sid, next);
|
|
2522
2662
|
this.sessions.add(next);
|
|
2663
|
+
this.lifecycle(`会话创建:sid=${sid} kind=local owner=${owner}${tmuxName !== null ? ` persist=${tmuxName}` : ''}${command !== null ? ' command' : ''}`);
|
|
2523
2664
|
// spawn 在途连接断开(0.19.0):cleanupAll 已跑过、扫不到此刻才入表的
|
|
2524
2665
|
// 会话——转孤儿(等重连 attach 或回收器清理)。不处理的话会话绑死已
|
|
2525
2666
|
// 关闭的 ws 且 orphanedAt 永为 null:回收器永不扫到,PTY 与名额永久泄漏,
|
|
@@ -2656,6 +2797,9 @@ export class TtyServer {
|
|
|
2656
2797
|
if (existing !== null) {
|
|
2657
2798
|
if (!conn.open)
|
|
2658
2799
|
return; // 等待在途创建期间连接断了:不往死连接上绑
|
|
2800
|
+
// 跨窗口共享的同一条 tmux 会话只有**一个** hidden 位:以本次帧为准(后写胜),
|
|
2801
|
+
// 与 `hidden` 帧同一条规则——不给共享会话另立一套语义
|
|
2802
|
+
this.setHiddenFromAgent(existing, msg.hidden === true);
|
|
2659
2803
|
this.rebindClient(existing, sid, ws, local, conn.id);
|
|
2660
2804
|
return;
|
|
2661
2805
|
}
|
|
@@ -2684,6 +2828,7 @@ export class TtyServer {
|
|
|
2684
2828
|
client: { ws, connId: conn.id },
|
|
2685
2829
|
local,
|
|
2686
2830
|
owner: 'user',
|
|
2831
|
+
hidden: msg.hidden === true,
|
|
2687
2832
|
});
|
|
2688
2833
|
}
|
|
2689
2834
|
catch (error) {
|
|
@@ -2730,6 +2875,7 @@ export class TtyServer {
|
|
|
2730
2875
|
if (existing !== null && existing.kind === 'ssh' && !existing.closed && existing.exited === null) {
|
|
2731
2876
|
if (!conn.open)
|
|
2732
2877
|
return; // 等待在途创建期间连接断了:不往死连接上绑
|
|
2878
|
+
this.setHiddenFromAgent(existing, msg.hidden === true); // 同上:共享会话一个 hidden 位
|
|
2733
2879
|
this.rebindClient(existing, sid, ws, local, conn.id);
|
|
2734
2880
|
return;
|
|
2735
2881
|
}
|
|
@@ -2778,6 +2924,7 @@ export class TtyServer {
|
|
|
2778
2924
|
exited: null,
|
|
2779
2925
|
paused: false,
|
|
2780
2926
|
owner: 'user',
|
|
2927
|
+
hiddenFromAgent: msg.hidden === true,
|
|
2781
2928
|
cwd: '',
|
|
2782
2929
|
kind: 'ssh',
|
|
2783
2930
|
target,
|
|
@@ -2809,6 +2956,7 @@ export class TtyServer {
|
|
|
2809
2956
|
};
|
|
2810
2957
|
local.set(sid, next);
|
|
2811
2958
|
this.sessions.add(next);
|
|
2959
|
+
this.lifecycle(`会话创建:sid=${sid} kind=ssh owner=user target=${target}${tmuxName !== null ? ` persist=${tmuxName}` : ''}`);
|
|
2812
2960
|
// spawn 在途连接断开(0.19.0):转孤儿,理由与本地分支相同;SSH 连接
|
|
2813
2961
|
// (含远程 tmux 持久会话)保持存活等重连 attach,到点由回收器收尾
|
|
2814
2962
|
if (!conn.open || ws.readyState !== WebSocket.OPEN) {
|
|
@@ -2880,6 +3028,22 @@ export class TtyServer {
|
|
|
2880
3028
|
}
|
|
2881
3029
|
}
|
|
2882
3030
|
}
|
|
3031
|
+
else if (msg.t === 'hidden') {
|
|
3032
|
+
// 「对 agent 不可见」(0.30.0):用户在自己面板里对**本条连接绑定的**标签切换。
|
|
3033
|
+
// 走 `local` 而不是全局 sid——能改这个位的只有持有该标签的那个窗口
|
|
3034
|
+
// (与 `resize` / `refresh` 同一条绑定语义);共享的 tmux 会话只此一个位,
|
|
3035
|
+
// 后写胜(与 spawn 帧重绑定时的规则一致)。
|
|
3036
|
+
const resolved = this.resolveSid(ws, msg, local);
|
|
3037
|
+
if (resolved === undefined || 'unknown' in resolved)
|
|
3038
|
+
return;
|
|
3039
|
+
const session = local.get(resolved.sid);
|
|
3040
|
+
if (session !== undefined && !session.closed) {
|
|
3041
|
+
this.setHiddenFromAgent(session, msg.hidden === true);
|
|
3042
|
+
// 回帧**如实回报结果**(不是回报请求):agent 开的会话会被上面的唯一写入口
|
|
3043
|
+
// 拒绝,客户端据实际值收敛标记——不回帧或回请求值,切换失败都长得像成功。
|
|
3044
|
+
send(ws, { t: 'hidden', sid: resolved.sid, hidden: session.hiddenFromAgent });
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
2883
3047
|
else if (msg.t === 'refresh') {
|
|
2884
3048
|
// 强制 tmux 重画(0.10.1):客户端 reset 清掉残 scrollback 后请宿主
|
|
2885
3049
|
// refresh-client 重画现场——不碰尺寸,规避隐藏标签下 proposeDimensions
|
|
@@ -3041,6 +3205,8 @@ export class TtyServer {
|
|
|
3041
3205
|
// `session.closed` 守卫要放保留态过去。终局之后的字节因 clients 已清空而不再
|
|
3042
3206
|
// 成帧(`onData` 照常入环形缓冲与屏:读到的是更完整的尾巴,不是更少的)。
|
|
3043
3207
|
session.exited = { code: outcome.exitCode, signal: outcome.signal, at: Date.now() };
|
|
3208
|
+
// 终局留痕(0.30.0):进程退出 ≠ 退役(D77 起转只读保留),这一行回答「它为什么不活着了」
|
|
3209
|
+
this.lifecycle(`会话结束:sid=${session.id} kind=${session.kind} owner=${session.owner} ${outcome.exitCode !== null ? `code=${String(outcome.exitCode)}` : `signal=${outcome.signal ?? '未知'}`}(转只读保留:输出与屏仍可读,直到显式关闭)`);
|
|
3044
3210
|
for (const victim of this.sessions.capExited(MAX_EXITED_SESSIONS)) {
|
|
3045
3211
|
// 被条数上限淘汰的:连同它在连接侧 local 表里的绑定一起摘掉(否则那条连接
|
|
3046
3212
|
// 还拿得到 sid、却指着一个已退役的会话)
|
|
@@ -4848,10 +5014,16 @@ const plugin = definePlugin({
|
|
|
4848
5014
|
},
|
|
4849
5015
|
},
|
|
4850
5016
|
render: (_args, value) => {
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
5017
|
+
// 局部名用 `list`:`sessions` 在这个作用域里是**宿主的 SessionManager**
|
|
5018
|
+
// (下面要问它 hiddenCount),叫 sessions 会把它遮住。
|
|
5019
|
+
const list = value?.sessions ?? [];
|
|
5020
|
+
// 隐藏标签(0.30.0):列表里没有它们,但**得让 agent 知道「没有」不等于
|
|
5021
|
+
// 「用户没开终端」**——否则它会把这句话原样转述给正盯着自己标签的用户。
|
|
5022
|
+
// 只报条数;文案与每轮 prompt 块**共用** hiddenSessionsNote(一处口径)。
|
|
5023
|
+
const hiddenNote = hiddenSessionsNote(sessions.hiddenCount());
|
|
5024
|
+
const text = list.length === 0
|
|
5025
|
+
? '当前没有可用的终端面板会话(可用 tty_open 自己开一个,或引导用户打开终端面板)。'
|
|
5026
|
+
: '终端面板会话:' + list.map((s) => {
|
|
4855
5027
|
const where = s.kind === 'ssh' ? `ssh ${s.target}` : `pid=${String(s.pid ?? '?')} cwd=${s.cwd}`;
|
|
4856
5028
|
const persist = s.persist === true ? ' [tmux 持久]' : '';
|
|
4857
5029
|
const owner = s.owner === 'agent' ? ' [agent 开的]' : '';
|
|
@@ -4874,14 +5046,18 @@ const plugin = definePlugin({
|
|
|
4874
5046
|
const gone = s.exited === true ? ` [已退出 ${detail}·只读保留${left}——只能读,写会报错]` : '';
|
|
4875
5047
|
return `\n- sid=${s.sid} [${s.kind}]${owner}${persist}${runMark}${lastExit}${gone} ${where} (启动于 ${new Date(s.startedAt).toLocaleString()})`;
|
|
4876
5048
|
}).join('');
|
|
4877
|
-
return [{ type: 'text', text }];
|
|
5049
|
+
return [{ type: 'text', text: text + hiddenNote }];
|
|
4878
5050
|
},
|
|
4879
5051
|
},
|
|
4880
5052
|
async execute() {
|
|
4881
5053
|
// D72:agent 第一次「看见」这些会话时把水位线落在当下——否则第一次
|
|
4882
5054
|
// tty_expect 会把用户早先的历史输出当成「还没读过的输出」回扫过来
|
|
5055
|
+
// 隐藏会话(0.30.0)**照旧被落水位线**:`forEach` 遍历全部会话,包括
|
|
5056
|
+
// `listForAgent` 列不出来的那些。这是有意的——水位线是「agent 已读到这里」,
|
|
5057
|
+
// 而隐藏期间产生的输出**本来就不该**在取消隐藏后被回扫给 agent;落水位线
|
|
5058
|
+
// 正好让那一段留在隐藏期之内。
|
|
4883
5059
|
sessions.forEach((session) => { ensureReadMark(session); });
|
|
4884
|
-
return { sessions: sessions.
|
|
5060
|
+
return { sessions: sessions.listForAgent() };
|
|
4885
5061
|
},
|
|
4886
5062
|
})));
|
|
4887
5063
|
activeDisposers.push(tools.register(defineTool({
|
|
@@ -5072,7 +5248,7 @@ const plugin = definePlugin({
|
|
|
5072
5248
|
const input = args;
|
|
5073
5249
|
if (typeof input.sid !== 'string' || input.sid === '')
|
|
5074
5250
|
throw new Error('sid 必须是非空字符串');
|
|
5075
|
-
const session = sessions.
|
|
5251
|
+
const session = sessions.agentView(input.sid);
|
|
5076
5252
|
if (session === undefined || session.closed)
|
|
5077
5253
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
5078
5254
|
if (session.exited !== null) {
|
|
@@ -5131,7 +5307,7 @@ const plugin = definePlugin({
|
|
|
5131
5307
|
const input = args;
|
|
5132
5308
|
if (typeof input.sid !== 'string' || input.sid === '')
|
|
5133
5309
|
throw new Error('sid 必须是非空字符串');
|
|
5134
|
-
const session = sessions.
|
|
5310
|
+
const session = sessions.agentView(input.sid);
|
|
5135
5311
|
if (session === undefined || session.closed)
|
|
5136
5312
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
5137
5313
|
const useRaw = input.raw === true;
|
|
@@ -5214,7 +5390,7 @@ const plugin = definePlugin({
|
|
|
5214
5390
|
const input = args;
|
|
5215
5391
|
if (typeof input.sid !== 'string' || input.sid === '')
|
|
5216
5392
|
throw new Error('sid 必须是非空字符串');
|
|
5217
|
-
const session = sessions.
|
|
5393
|
+
const session = sessions.agentView(input.sid);
|
|
5218
5394
|
if (session === undefined || session.closed)
|
|
5219
5395
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
5220
5396
|
const screen = session.screen;
|
|
@@ -5288,7 +5464,7 @@ const plugin = definePlugin({
|
|
|
5288
5464
|
throw new Error('sid 必须是非空字符串');
|
|
5289
5465
|
if (typeof input.pattern !== 'string' || input.pattern === '')
|
|
5290
5466
|
throw new Error('pattern 必须是非空字符串');
|
|
5291
|
-
const session = sessions.
|
|
5467
|
+
const session = sessions.agentView(input.sid);
|
|
5292
5468
|
if (session === undefined || session.closed)
|
|
5293
5469
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
5294
5470
|
let re;
|
|
@@ -5435,7 +5611,7 @@ const plugin = definePlugin({
|
|
|
5435
5611
|
}
|
|
5436
5612
|
// 按键名先解析(未知名字在这里就报错,别等写进 PTY 才发现)
|
|
5437
5613
|
const keyBytes = resolveKeys(keys);
|
|
5438
|
-
const session = sessions.
|
|
5614
|
+
const session = sessions.agentView(input.sid);
|
|
5439
5615
|
if (session === undefined || session.closed)
|
|
5440
5616
|
throw new Error(`会话不存在或已退出: ${input.sid}`);
|
|
5441
5617
|
if (session.exited !== null) {
|
|
@@ -5994,12 +6170,16 @@ const plugin = definePlugin({
|
|
|
5994
6170
|
name: 'plugin:dsh-tty:terminals',
|
|
5995
6171
|
order: 150,
|
|
5996
6172
|
text: () => {
|
|
5997
|
-
const list = sessions.
|
|
6173
|
+
const list = sessions.listForAgent();
|
|
5998
6174
|
// D78 补:本地命令的语法随宿主 shell 变(POSIX / cmd / PowerShell),
|
|
5999
6175
|
// 而「Shell 路径」是热改的配置——所以这行每轮现算,不冻在工具描述里。
|
|
6000
6176
|
const shellLine = commandShellHint(live.shell);
|
|
6177
|
+
// 被用户隐藏的标签(0.30.0):**只报条数**,不报 sid / cwd / 活动。没有
|
|
6178
|
+
// 这一行时,上面列不出任何会话会被读成「用户没开终端」,模型就会回一句
|
|
6179
|
+
// 「当前没有终端面板会话」——而用户正盯着自己的标签(见 hiddenCount)。
|
|
6180
|
+
const hiddenNote = hiddenSessionsNote(sessions.hiddenCount());
|
|
6001
6181
|
if (list.length === 0)
|
|
6002
|
-
return
|
|
6182
|
+
return `当前没有可用的终端面板会话(可用 tty_open 自己开一个,或引导用户打开「终端」面板)。${hiddenNote}\n${shellLine}`;
|
|
6003
6183
|
// D77:只读保留态(进程已退出)**不能冒充活会话**——模型会以为那个长驻
|
|
6004
6184
|
// 任务还在跑、或者对它发命令。这里把两者分开:活会话逐条列,保留态压成
|
|
6005
6185
|
// 一行汇总(每轮 prompt 的增量是常数,不随条数线性膨胀)。
|
|
@@ -6028,7 +6208,7 @@ const plugin = definePlugin({
|
|
|
6028
6208
|
const how = s.signal !== undefined ? `signal=${s.signal}` : s.exitCode === undefined ? '退出码未知' : `exitCode=${String(s.exitCode)}`;
|
|
6029
6209
|
return `sid=${s.sid} (${how})`;
|
|
6030
6210
|
}).join('、');
|
|
6031
|
-
return head + tail + '\n' + shellLine;
|
|
6211
|
+
return head + tail + hiddenNote + '\n' + shellLine;
|
|
6032
6212
|
},
|
|
6033
6213
|
});
|
|
6034
6214
|
sectionDisposable = systemPrompt.section({ name: 'plugin:dsh-tty', order: 150, text: TTY_GUIDANCE });
|
|
@@ -6059,7 +6239,15 @@ const plugin = definePlugin({
|
|
|
6059
6239
|
// 孤儿会话回收器:超过保活期的异常断开会话定期清理(grace=0 时为 no-op,
|
|
6060
6240
|
// 断开时立即结束);插件卸载时随 effect 一起停掉
|
|
6061
6241
|
const reaperTimer = setInterval(() => {
|
|
6062
|
-
void sessions.reapOrphans(live.reconnectGraceMs)
|
|
6242
|
+
void sessions.reapOrphans(live.reconnectGraceMs).then((reaped) => {
|
|
6243
|
+
/*
|
|
6244
|
+
* 「断线太久 → 孤儿被回收」是「标签为什么自己没了」最常见的答案,必须留痕,
|
|
6245
|
+
* 而且是 warn 档:它真的结束了用户的东西(与只读保留超限淘汰同一档)。
|
|
6246
|
+
*/
|
|
6247
|
+
if (reaped.length === 0)
|
|
6248
|
+
return;
|
|
6249
|
+
ctx.logger.warn(reapedOrphansMessage(reaped.map((session) => session.id), Math.round(live.reconnectGraceMs / 1000)));
|
|
6250
|
+
});
|
|
6063
6251
|
sessions.reapExited(EXITED_RETAIN_MS); // D77:保留期策略为有限值时到点退役(∞ 时不动作,条数由 capExited 兜)
|
|
6064
6252
|
}, REAPER_INTERVAL_MS);
|
|
6065
6253
|
reaperTimer.unref?.();
|
|
@@ -6068,15 +6256,29 @@ const plugin = definePlugin({
|
|
|
6068
6256
|
// 回调里,写入路径的同步 try/catch 结构性拦不住;没有兜底时任何一处虚拟屏异常
|
|
6069
6257
|
// 都会直接打死宿主进程(Web GUI 掉线、会话表清空、agent 全丢)。插件卸载时摘掉。
|
|
6070
6258
|
ctx.effect(() => installXtermScreenCrashGuard(), 'dsh-tty: xterm crash guard');
|
|
6259
|
+
// 本次挂载的**进程级**序号:卸载清点与挂载留痕共用同一个号(见 nextMountSerial)
|
|
6260
|
+
const mountSerial = nextMountSerial();
|
|
6071
6261
|
// 插件卸载时回收全部会话、隧道与 SFTP 连接
|
|
6072
6262
|
ctx.effect(() => {
|
|
6073
6263
|
return () => {
|
|
6264
|
+
/*
|
|
6265
|
+
* 卸载清点(0.30.0):**实例被换掉 = 这张会话表随之清空**,面板上的标签会跟着消失,
|
|
6266
|
+
* 非 tmux 会话的内容再也回不来。所以「第几次挂载的那个实例被卸了、卸的时候还剩几条」
|
|
6267
|
+
* 是那类现象的第一手证据——必须打在 `disposeAll()` **之前**(之后表就空了)。
|
|
6268
|
+
*/
|
|
6269
|
+
ctx.logger.info(`[dsh-tty] 插件卸载(本进程第 ${String(mountSerial)} 次挂载的实例):清点 ${String(sessions.liveCount)} 个在线会话、${String(sessions.exitedCount)} 个只读保留 —— 一并结束(非 tmux 会话的内容不可恢复)`);
|
|
6074
6270
|
void sessions.disposeAll();
|
|
6075
6271
|
tunnelManager.disposeAll();
|
|
6076
6272
|
sftpManager.disposeAll();
|
|
6077
6273
|
};
|
|
6078
6274
|
}, 'dsh-tty: session cleanup');
|
|
6079
|
-
|
|
6275
|
+
/*
|
|
6276
|
+
* 挂载留痕(0.30.0):**序号是进程级的**(见 `nextMountSerial`)——同一个 pid 里出现
|
|
6277
|
+
* 「第 2 次挂载」就意味着插件被重挂过一次,而那次重挂正是「标签自己消失」的头号嫌疑。
|
|
6278
|
+
* `tmux socket` 也写进来:`dsh --profile X` 起的宿主里 `DSH_PROFILE` 可能是空的,于是它
|
|
6279
|
+
* 和别的宿主共用裸 `dsh-tty`(2026-10-10 实测)——这种事只有打出来才看得见。
|
|
6280
|
+
*/
|
|
6281
|
+
ctx.logger.info(`[dsh-tty] 插件挂载(本进程第 ${String(mountSerial)} 次,pid=${String(process.pid)}):持久化=${live.persistence} 断线保活=${String(Math.round(live.reconnectGraceMs / 1000))}s 并发上限=${String(sessions.limitValue)} tmux socket=${tmuxSocketName()} shell=${live.shell} cwd=${live.cwd}`);
|
|
6080
6282
|
},
|
|
6081
6283
|
});
|
|
6082
6284
|
export const { name, inject, apply } = plugin;
|