@hyzyn/dsh-tty 0.31.0 → 0.32.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 +43 -14
- package/README.md +38 -10
- package/client.js +29 -29
- package/lib/index.d.ts +69 -7
- package/lib/index.js +207 -23
- package/lib/index.js.map +1 -1
- package/lib/shell-integration.d.ts +10 -0
- package/lib/shell-integration.js +171 -9
- package/lib/shell-integration.js.map +1 -1
- package/package.json +1 -1
- package/scripts/pty-kill-probe.cjs +123 -0
- package/scripts/pty-kill-repro-plugin.mjs +72 -0
- package/scripts/pty-kill-repro.mjs +101 -0
- package/scripts/pwsh-probe.mjs +411 -0
- package/scripts/windows-smoke.mjs +97 -2
package/lib/index.d.ts
CHANGED
|
@@ -51,9 +51,10 @@
|
|
|
51
51
|
* reconnectGraceSec(默认 120s,0 = 旧行为立即结束),等待新连接 attach
|
|
52
52
|
* 并回放 256KB 环形缓冲;到点由回收器清理。
|
|
53
53
|
*
|
|
54
|
-
* shell 集成(src/shell-integration.ts,0.4.0):spawn
|
|
55
|
-
*
|
|
56
|
-
*
|
|
54
|
+
* shell 集成(src/shell-integration.ts,0.4.0):spawn 时注入 OSC 133/7 钩子,
|
|
55
|
+
* 输出流解析出命令边界(tty_capture{last} / tty_expect 早停)与实时 cwd(tty_list)。
|
|
56
|
+
* 注入形态按平台分:POSIX 走 `-c` 包装层 + rc 桩(zsh ZDOTDIR 桩 / bash --rcfile 桩),
|
|
57
|
+
* Windows 走 PowerShell 的 `-File` 稳定桩(cmd 没有命令边界钩子,不注入)。
|
|
57
58
|
* 辅助路由:/api/dsh-tty/ssh-config(~/.ssh/config 导入候选)、
|
|
58
59
|
* /api/dsh-tty/credential-refs(凭据存储里已知的引用名,SSH 对话框选择器候选,只要名字)、
|
|
59
60
|
* /api/dsh-tty/env-vars(env 插件托管变量名;SSH 对话框已改从连接簿 + 凭据存储取候选,此路由保留兼容)、
|
|
@@ -223,10 +224,24 @@ interface PtyHandle {
|
|
|
223
224
|
terminal?: {
|
|
224
225
|
resize?(cols: number, rows: number): void;
|
|
225
226
|
kill?(signal: string): void;
|
|
227
|
+
/**
|
|
228
|
+
* node-pty 的 conout 代理(`WindowsPtyAgent`,**只有 Windows 的
|
|
229
|
+
* `WindowsTerminal` 有这个字段**)——D100 的一次性原生 kill 闸门装在这里。
|
|
230
|
+
* 它是 `_ptyNative.kill(id)` 唯一的调用点,所以也是唯一能拦住「同一个 pty 被杀两次」
|
|
231
|
+
* 的位置(见 installWindowsKillGate)。
|
|
232
|
+
*/
|
|
233
|
+
_agent?: {
|
|
234
|
+
kill?(): unknown;
|
|
235
|
+
/** 闸门已安装的标记(本包自己加的字段,node-pty 不认)。 */
|
|
236
|
+
__dshKillGated?: boolean;
|
|
237
|
+
};
|
|
226
238
|
};
|
|
227
239
|
}
|
|
228
|
-
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。
|
|
229
|
-
|
|
240
|
+
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。
|
|
241
|
+
*
|
|
242
|
+
* `platform` 只为单测注入(D100 的闸门是 win32-only,macOS/Linux 上要能断言两条分支)。
|
|
243
|
+
*/
|
|
244
|
+
export declare function wrapLocalPty(handle: PtyHandle, platform?: NodeJS.Platform): TermHandle;
|
|
230
245
|
/**
|
|
231
246
|
* 本地 PTY 顶层 shell 的 best-effort 强杀(D48)。
|
|
232
247
|
*
|
|
@@ -240,6 +255,45 @@ export declare function wrapLocalPty(handle: PtyHandle): TermHandle;
|
|
|
240
255
|
* 导出仅供单测(test/host-frames.test.ts):平台参数注入,两个分支都能在 macOS/Linux 断言。
|
|
241
256
|
*/
|
|
242
257
|
export declare function killLocalShellTerminal(terminal: unknown, platform?: NodeJS.Platform): void;
|
|
258
|
+
/**
|
|
259
|
+
* 一次性原生 kill 闸门(D100,2026-10-12 Windows 真机实测)。
|
|
260
|
+
*
|
|
261
|
+
* **为什么必须有**:node-pty 的 `WindowsTerminal.kill()` 落到 `WindowsPtyAgent.kill()`,
|
|
262
|
+
* 后者对同一个 `_pty` 调 `conpty.node` 的 `kill(id)`;而 `conpty.cc` 的 `PtyKill` **不幂等**——
|
|
263
|
+
* 它 `ClosePseudoConsole(handle->hpc)` 之后**不置空 `hpc`**,那条 baton 要等 shell 进程真的退出、
|
|
264
|
+
* 由 exit 线程从 `ptyHandles` 里 `erase_if` 摘掉才算消失。这段窗口里再调一次,同一个**已释放的
|
|
265
|
+
* HPCON** 会被第二次交给 `ClosePseudoConsole`。
|
|
266
|
+
*
|
|
267
|
+
* 而 Windows 上**两个调用方必然叠加**:
|
|
268
|
+
* 1. DSH 的 `LocalTerminalHandle.stopShellWindows()` 在 `rootIdentity === undefined` 时调
|
|
269
|
+
* `terminal.kill()`;而 Windows 上 rootIdentity **恒为 undefined**——node-pty 的 `pid` 要等
|
|
270
|
+
* conout 连接建立才有值,DSH 在 `new LocalTerminalHandle(...)` 那一刻就取 `terminal.pid`,
|
|
271
|
+
* 拿到的是 **0**(真机探针逐轮实测 `spawned pid=0`;真实 pid 直到 `connect` 才出现),
|
|
272
|
+
* 于是 `snapshot().tree(0)` 找不到自己、必然走那条兜底分支;
|
|
273
|
+
* 2. 本插件 D48 的预杀(`killSessionNow` 里的 `handle.forceKill?.()`)又调一次。
|
|
274
|
+
* 两次叠加 = 同一个 HPCON 被 ClosePseudoConsole 两次 = **STATUS_HEAP_CORRUPTION(0xC0000374)**,
|
|
275
|
+
* 被 Windows 打死的是**整个宿主进程**,不是某一个会话。
|
|
276
|
+
*
|
|
277
|
+
* **实测**(Win11 ARM64,同一 harness、同一 `kill+terminate` 顺序,各 150 轮,只差这一个变量):
|
|
278
|
+
* 无闸门 → 累计 94 次双重原生 kill,第 75 轮宿主崩(退出码 -1073740940);
|
|
279
|
+
* 装闸门 → 150/150 每 pty 恰好 1 次原生 kill、0 次双重,跑满 150 轮不崩;
|
|
280
|
+
* 单次 kill 的对照(只调 terminate)150 轮也不崩。
|
|
281
|
+
*
|
|
282
|
+
* **为什么装在 `_agent.kill`(执行点)而不是 `terminal.kill`(调用点)**:node-pty 在终端 ready
|
|
283
|
+
* 之前会把自己收到的 kill **排队**(`_deferNoArgs`),到第一次 data 才执行。装在调用点的话
|
|
284
|
+
* 「排队的那次」会先把闸门占掉、DSH 真到执行的那次被吞——万一排队的那次永远不执行(进程没产出
|
|
285
|
+
* 数据、deferred 从不跑),就变成**一个都没杀**(进程泄漏)。装在执行点则谁先真正动手谁生效、
|
|
286
|
+
* 后到的才被吞,两个方向的坏结果都不会出现。
|
|
287
|
+
*
|
|
288
|
+
* **边界**:只在 win32 装——POSIX 的 `stopShell` 本来就要 SIGTERM→SIGKILL **两次**,吞第二次是错的。
|
|
289
|
+
* `_agent` 是 node-pty 的私有字段(与 `terminal.resize` 同级的内部耦合):取不到时返回 false,
|
|
290
|
+
* 调用方记一条警告后照旧调用——退化的后果是回到「双重 kill」(今天的现状),不是新增故障(D79 同款)。
|
|
291
|
+
*
|
|
292
|
+
* 导出仅供单测(test/host-frames.test.ts):平台参数注入,两个分支都能在 macOS/Linux 断言。
|
|
293
|
+
*
|
|
294
|
+
* @returns 闸门是否已就位(非 win32 恒 true)
|
|
295
|
+
*/
|
|
296
|
+
export declare function installWindowsKillGate(handle: PtyHandle, platform?: NodeJS.Platform): boolean;
|
|
243
297
|
/**
|
|
244
298
|
* Windows 本地 PTY 的**输入归一化**(D74,2026-09-27 真机报告):conhost 把 Enter 当
|
|
245
299
|
* **CR**,裸 LF 只把光标下移一格、**不提交命令行**——于是 `tty_send` 按工具描述发
|
|
@@ -480,12 +534,20 @@ interface ShellIntegrationState {
|
|
|
480
534
|
carry: string;
|
|
481
535
|
/**
|
|
482
536
|
* 这个会话**见过至少一个 OSC 133 标记**(0.23.0)。tty_list 的 `running` 能不能
|
|
483
|
-
* 下结论全看它:没有标记 = 命令边界不可信(非持久 SSH / fish·csh / Windows
|
|
537
|
+
* 下结论全看它:没有标记 = 命令边界不可信(非持久 SSH / fish·csh / Windows 的 cmd /
|
|
484
538
|
* 集成被关 / tmux <3.3 吞了 DCS 信封),此时**必须报「未知」而不是「没在跑」**。
|
|
485
539
|
*/
|
|
486
540
|
sawMark: boolean;
|
|
487
541
|
/** B..D 之间:命令输出捕获中。 */
|
|
488
542
|
inCommand: boolean;
|
|
543
|
+
/**
|
|
544
|
+
* 当前这条命令**开始的时刻**(见到那个 `B` 的 Date.now();0 = 还没见过 B)。
|
|
545
|
+
*
|
|
546
|
+
* 用途只有一个(D101):区分「命令行回显」与「往在跑的程序里敲的字」。命令行的回显是**在 B
|
|
547
|
+
* 之前**提交的那次 `tty_send`(`session.lastInputAt < commandStartedAt`),而 TUI 里的按键
|
|
548
|
+
* 一定晚于 B —— 后者在屏上出现是真实输出,不许当回显剔掉(D75 的边界)。
|
|
549
|
+
*/
|
|
550
|
+
commandStartedAt: number;
|
|
489
551
|
cmdBuffer: string;
|
|
490
552
|
/** T 标记带来的 pane 快照(tmux 持久标签;D 时优先于 cmdBuffer)。 */
|
|
491
553
|
pendingT: string | null;
|
|
@@ -767,7 +829,7 @@ export interface SessionSnapshot {
|
|
|
767
829
|
/**
|
|
768
830
|
* 有命令正在执行(0.23.0):true = 在跑;false = 命令已结束(或进程已退出);
|
|
769
831
|
* **省略 = 无法判断**——这个会话没有 shell 集成标记(非持久 SSH / fish·csh /
|
|
770
|
-
* Windows
|
|
832
|
+
* Windows 的 cmd / 集成被关 / tmux <3.3 吞了信封),或命令型会话尚未收到终局。
|
|
771
833
|
* 拿它当「现在可以往里发命令」的许可时要按三态处理:省略 ≠ 没在跑。
|
|
772
834
|
*/
|
|
773
835
|
running?: boolean;
|
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, tmuxSocketName } from './shell-integration.js';
|
|
20
|
+
import { buildCommandSpawn, buildShellSpawn, commandShellHint, defaultShellPath, shellIntegrationSupported, 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';
|
|
@@ -312,9 +312,18 @@ const TTY_GUIDANCE = '**先判断要不要用终端**:只有「需要跨调用
|
|
|
312
312
|
+ '标签栏「+」还能开 SSH 标签(ssh2 原生连接;连接簿、agent forwarding、主机指纹 TOFU、单跳跳板机都在设置卡片维护);面板内另有 SFTP 文件浏览(上传/下载/建目录/重命名/删除),以及由连接簿条目配置的端口转发隧道;设置卡片开「会话持久化(tmux)」后新标签由 tmux 托管,能跨宿主重启恢复,长任务建议开着它跑。'
|
|
313
313
|
+ '用户提到「开个终端 / 在终端里跑 / SSH 到某台机器 / 传文件 / 转发端口」时,引导他打开这个面板。'
|
|
314
314
|
+ '典型流程:tty_send 启动长任务 → tty_expect 等就绪标记 → tty_capture{last:true} 拿结果。';
|
|
315
|
-
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。
|
|
316
|
-
|
|
315
|
+
/** 本地 PTY 包装成 TermHandle(resize/kill 仍是透传 node-pty 的内部耦合;防御性降级)。
|
|
316
|
+
*
|
|
317
|
+
* `platform` 只为单测注入(D100 的闸门是 win32-only,macOS/Linux 上要能断言两条分支)。
|
|
318
|
+
*/
|
|
319
|
+
export function wrapLocalPty(handle, platform = process.platform) {
|
|
317
320
|
let resizeWarned = false;
|
|
321
|
+
// D100:在 DSH 拿到这个 terminal 之前装好一次性原生 kill 闸门(D48 的预杀与 DSH 的
|
|
322
|
+
// stopShellWindows 兜底会调同一个 terminal.kill(),而这个原生调用不幂等)。
|
|
323
|
+
if (!installWindowsKillGate(handle, platform) && !killGateUnavailableLogged) {
|
|
324
|
+
killGateUnavailableLogged = true;
|
|
325
|
+
console.warn('[dsh-tty] 未能在 node-pty 的 conout 代理(terminal._agent)上安装一次性 kill 闸门(D100):node-pty 内部结构可能已变化。Windows 上「插件的预杀 + DSH 的兜底 kill」叠加时会把同一个 HPCON 交给 ClosePseudoConsole 两次,实测可打死宿主进程(0xC0000374)');
|
|
326
|
+
}
|
|
318
327
|
// D79:README 承诺「DSH 升级若改内部结构会警告一次并退化为固定尺寸」,但老写法是
|
|
319
328
|
// `handle.terminal?.resize?.(…)` —— `?.` 只在**抛错**时才进 catch,DSH 若把
|
|
320
329
|
// `handle.terminal` 改名 / 移除(而不是让 resize 抛错),这里既不 resize 也不警告,
|
|
@@ -376,6 +385,79 @@ export function killLocalShellTerminal(terminal, platform = process.platform) {
|
|
|
376
385
|
/* 已退出 */
|
|
377
386
|
}
|
|
378
387
|
}
|
|
388
|
+
/** 「闸门没装上」只在这一处记一次(每个会话 spawn 都记就是刷屏;见 D100)。 */
|
|
389
|
+
let killGateUnavailableLogged = false;
|
|
390
|
+
/** 「闸门吞掉了一次重复终止」只记一次(正常路径上每次 Windows kill 都会命中,逐次记就是刷屏)。 */
|
|
391
|
+
let killGateSwallowedLogged = false;
|
|
392
|
+
/**
|
|
393
|
+
* 一次性原生 kill 闸门(D100,2026-10-12 Windows 真机实测)。
|
|
394
|
+
*
|
|
395
|
+
* **为什么必须有**:node-pty 的 `WindowsTerminal.kill()` 落到 `WindowsPtyAgent.kill()`,
|
|
396
|
+
* 后者对同一个 `_pty` 调 `conpty.node` 的 `kill(id)`;而 `conpty.cc` 的 `PtyKill` **不幂等**——
|
|
397
|
+
* 它 `ClosePseudoConsole(handle->hpc)` 之后**不置空 `hpc`**,那条 baton 要等 shell 进程真的退出、
|
|
398
|
+
* 由 exit 线程从 `ptyHandles` 里 `erase_if` 摘掉才算消失。这段窗口里再调一次,同一个**已释放的
|
|
399
|
+
* HPCON** 会被第二次交给 `ClosePseudoConsole`。
|
|
400
|
+
*
|
|
401
|
+
* 而 Windows 上**两个调用方必然叠加**:
|
|
402
|
+
* 1. DSH 的 `LocalTerminalHandle.stopShellWindows()` 在 `rootIdentity === undefined` 时调
|
|
403
|
+
* `terminal.kill()`;而 Windows 上 rootIdentity **恒为 undefined**——node-pty 的 `pid` 要等
|
|
404
|
+
* conout 连接建立才有值,DSH 在 `new LocalTerminalHandle(...)` 那一刻就取 `terminal.pid`,
|
|
405
|
+
* 拿到的是 **0**(真机探针逐轮实测 `spawned pid=0`;真实 pid 直到 `connect` 才出现),
|
|
406
|
+
* 于是 `snapshot().tree(0)` 找不到自己、必然走那条兜底分支;
|
|
407
|
+
* 2. 本插件 D48 的预杀(`killSessionNow` 里的 `handle.forceKill?.()`)又调一次。
|
|
408
|
+
* 两次叠加 = 同一个 HPCON 被 ClosePseudoConsole 两次 = **STATUS_HEAP_CORRUPTION(0xC0000374)**,
|
|
409
|
+
* 被 Windows 打死的是**整个宿主进程**,不是某一个会话。
|
|
410
|
+
*
|
|
411
|
+
* **实测**(Win11 ARM64,同一 harness、同一 `kill+terminate` 顺序,各 150 轮,只差这一个变量):
|
|
412
|
+
* 无闸门 → 累计 94 次双重原生 kill,第 75 轮宿主崩(退出码 -1073740940);
|
|
413
|
+
* 装闸门 → 150/150 每 pty 恰好 1 次原生 kill、0 次双重,跑满 150 轮不崩;
|
|
414
|
+
* 单次 kill 的对照(只调 terminate)150 轮也不崩。
|
|
415
|
+
*
|
|
416
|
+
* **为什么装在 `_agent.kill`(执行点)而不是 `terminal.kill`(调用点)**:node-pty 在终端 ready
|
|
417
|
+
* 之前会把自己收到的 kill **排队**(`_deferNoArgs`),到第一次 data 才执行。装在调用点的话
|
|
418
|
+
* 「排队的那次」会先把闸门占掉、DSH 真到执行的那次被吞——万一排队的那次永远不执行(进程没产出
|
|
419
|
+
* 数据、deferred 从不跑),就变成**一个都没杀**(进程泄漏)。装在执行点则谁先真正动手谁生效、
|
|
420
|
+
* 后到的才被吞,两个方向的坏结果都不会出现。
|
|
421
|
+
*
|
|
422
|
+
* **边界**:只在 win32 装——POSIX 的 `stopShell` 本来就要 SIGTERM→SIGKILL **两次**,吞第二次是错的。
|
|
423
|
+
* `_agent` 是 node-pty 的私有字段(与 `terminal.resize` 同级的内部耦合):取不到时返回 false,
|
|
424
|
+
* 调用方记一条警告后照旧调用——退化的后果是回到「双重 kill」(今天的现状),不是新增故障(D79 同款)。
|
|
425
|
+
*
|
|
426
|
+
* 导出仅供单测(test/host-frames.test.ts):平台参数注入,两个分支都能在 macOS/Linux 断言。
|
|
427
|
+
*
|
|
428
|
+
* @returns 闸门是否已就位(非 win32 恒 true)
|
|
429
|
+
*/
|
|
430
|
+
export function installWindowsKillGate(handle, platform = process.platform) {
|
|
431
|
+
if (platform !== 'win32')
|
|
432
|
+
return true;
|
|
433
|
+
const terminal = handle?.terminal;
|
|
434
|
+
// 不是真 node-pty 终端(测试夹具 / 未来结构变化)不算「闸门装不上」:先看有没有 kill
|
|
435
|
+
if (terminal === undefined || typeof terminal.kill !== 'function')
|
|
436
|
+
return true;
|
|
437
|
+
const agent = terminal._agent;
|
|
438
|
+
if (agent === undefined || typeof agent.kill !== 'function')
|
|
439
|
+
return false;
|
|
440
|
+
if (agent.__dshKillGated === true)
|
|
441
|
+
return true;
|
|
442
|
+
agent.__dshKillGated = true;
|
|
443
|
+
const real = agent.kill;
|
|
444
|
+
let killed = false;
|
|
445
|
+
agent.kill = function () {
|
|
446
|
+
if (killed) {
|
|
447
|
+
// 这一行就是「闸门真的拦到了一次双重终止」的证据:排查 Windows 上的宿主崩溃时有它可查
|
|
448
|
+
if (!killGateSwallowedLogged) {
|
|
449
|
+
killGateSwallowedLogged = true;
|
|
450
|
+
console.warn('[dsh-tty] 原生 kill 闸门已生效:同一个 PTY 的重复终止被吞掉(node-pty 的产线 kill 不幂等,重复调用会把已释放的 HPCON 再交给 ClosePseudoConsole,实测会打死宿主进程;见 D100)');
|
|
451
|
+
}
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
// 先落标记再真调(失败也不放开):放开的代价是可能再来一次双重 kill → 宿主崩溃;
|
|
455
|
+
// 不放开的代价只是「这一次没杀掉」,而调用方还有 terminate 兜底。
|
|
456
|
+
killed = true;
|
|
457
|
+
return real.call(this);
|
|
458
|
+
};
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
379
461
|
/**
|
|
380
462
|
* Windows 本地 PTY 的**输入归一化**(D74,2026-09-27 真机报告):conhost 把 Enter 当
|
|
381
463
|
* **CR**,裸 LF 只把光标下移一格、**不提交命令行**——于是 `tty_send` 按工具描述发
|
|
@@ -686,7 +768,7 @@ const FLUSH_INTERVAL_MS = 12;
|
|
|
686
768
|
/** 待发输出超过该字符数时跳过窗口立即冲刷(防超长输出无限延迟)。 */
|
|
687
769
|
const FLUSH_SIZE_CHARS = 64 * 1024;
|
|
688
770
|
function createShellState() {
|
|
689
|
-
return { carry: '', sawMark: false, inCommand: false, cmdBuffer: '', pendingT: null, lastCommand: null, cmdSeq: 0 };
|
|
771
|
+
return { carry: '', sawMark: false, inCommand: false, commandStartedAt: 0, cmdBuffer: '', pendingT: null, lastCommand: null, cmdSeq: 0 };
|
|
690
772
|
}
|
|
691
773
|
/** OSC 133;T 的 base64 payload → utf8 文本(无效输入返回 null)。 */
|
|
692
774
|
function decodeBase64Utf8(payload) {
|
|
@@ -701,7 +783,18 @@ function osc7Path(body) {
|
|
|
701
783
|
const url = new URL(body);
|
|
702
784
|
if (url.protocol !== 'file:')
|
|
703
785
|
return undefined;
|
|
704
|
-
|
|
786
|
+
let decoded = decodeURIComponent(url.pathname);
|
|
787
|
+
/*
|
|
788
|
+
* Windows 盘符:PowerShell 桩用 `[Uri]::AbsoluteUri` 上报,`D:\DSH\x` 于是变成
|
|
789
|
+
* `file:///D:/DSH/x`、`URL.pathname` 是 `/D:/DSH/x` —— 去掉盘符前那根多余的斜杠才是
|
|
790
|
+
* 可用的 Windows 路径(`/D:/x` 形态拿去 spawn 会被当成相对盘根的路径)。
|
|
791
|
+
*
|
|
792
|
+
* UNC(`\\server\share` → `file://server/share`)的服务器段住在 host 里,而本函数按
|
|
793
|
+
* 既有语义(POSIX shell 上报的 host 是本机名)忽略 host,所以 UNC cwd 只显示成
|
|
794
|
+
* `/share/…`:纯展示降级,不参与任何 spawn 决策。
|
|
795
|
+
*/
|
|
796
|
+
if (process.platform === 'win32' && /^\/[A-Za-z]:[\\/]/.test(decoded))
|
|
797
|
+
decoded = decoded.slice(1);
|
|
705
798
|
return decoded !== '' ? decoded : undefined;
|
|
706
799
|
}
|
|
707
800
|
catch {
|
|
@@ -749,6 +842,7 @@ export function feedShellIntegration(session, text) {
|
|
|
749
842
|
const kind = match[1];
|
|
750
843
|
if (kind === 'B') {
|
|
751
844
|
state.inCommand = true;
|
|
845
|
+
state.commandStartedAt = Date.now();
|
|
752
846
|
state.cmdBuffer = '';
|
|
753
847
|
state.pendingT = null;
|
|
754
848
|
}
|
|
@@ -1081,6 +1175,66 @@ function lastCommandStart(text) {
|
|
|
1081
1175
|
OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
|
|
1082
1176
|
return end;
|
|
1083
1177
|
}
|
|
1178
|
+
/**
|
|
1179
|
+
* 从原始流里取出**命令输出**的部分(D101):丢掉每个 `A`(prompt 开始)到其后第一个 `B`
|
|
1180
|
+
* (命令开始)之间的文本,以及末尾那段还没等到 `B` 的 `A` 段。
|
|
1181
|
+
*
|
|
1182
|
+
* 为什么需要它:`A..B` 之间只有提示符与**命令行回显**——真实输出一定在 `B` 之后。这是
|
|
1183
|
+
* shell 集成的硬边界(不是文本启发式)。回溯路径一直在用它(`lastCommandStart` 从最后一个
|
|
1184
|
+
* `B` 起切),而 live 路径此前拿**整段原始流**去匹配,于是「字面出现在命令行里」的 pattern
|
|
1185
|
+
* 会命中自己的回显、秒回 matched —— 命令其实还在跑(D75 的文本剔除在窗口里出现过任意 `B`
|
|
1186
|
+
* 标记时也不启用,见 `echoStrippable`)。
|
|
1187
|
+
*
|
|
1188
|
+
* 真机实测(2026-10-13,Windows 11 ARM64 + pwsh 7.6.6 / Win PowerShell 5.1.26100):命令
|
|
1189
|
+
* `Start-Sleep -Seconds 2; echo PROBE_ECHO_QQQ` + 等 `PROBE_ECHO_QQQ`,两版分别在 **432ms /
|
|
1190
|
+
* 446ms** 就返回 matched;macOS/zsh 上同类用例 **1ms**。修法即本函数:命中前先按边界裁掉
|
|
1191
|
+
* 提示符/回显段。
|
|
1192
|
+
*
|
|
1193
|
+
* 没有标记的会话(cmd、集成被关、TUI 全屏重画)原样返回——降级语义不变。
|
|
1194
|
+
*
|
|
1195
|
+
* 用在两处:`unreadRegion`(回溯窗口,从最后一个 `B` 起切之后再做一次)与
|
|
1196
|
+
* `liveOutputRegion` 的「窗口里没有 `B`」那一支。
|
|
1197
|
+
*/
|
|
1198
|
+
function commandOutputRegion(text) {
|
|
1199
|
+
let result = '';
|
|
1200
|
+
let cursor = 0;
|
|
1201
|
+
let inPrompt = false;
|
|
1202
|
+
OSC133_RE.lastIndex = 0;
|
|
1203
|
+
for (const match of text.matchAll(OSC133_RE)) {
|
|
1204
|
+
const index = match.index ?? 0;
|
|
1205
|
+
if (!inPrompt)
|
|
1206
|
+
result += text.slice(cursor, index);
|
|
1207
|
+
if (match[1] === 'A')
|
|
1208
|
+
inPrompt = true;
|
|
1209
|
+
else if (match[1] === 'B')
|
|
1210
|
+
inPrompt = false;
|
|
1211
|
+
cursor = index + match[0].length;
|
|
1212
|
+
}
|
|
1213
|
+
if (!inPrompt)
|
|
1214
|
+
result += text.slice(cursor);
|
|
1215
|
+
OSC133_RE.lastIndex = 0; // 归位:这个正则是共用的(feedShellIntegration 也用它)
|
|
1216
|
+
return result;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* `tty_expect` 的 live 窗口:从「本次等待期间到达的原始流」里取出**命令输出**(D101)。
|
|
1220
|
+
*
|
|
1221
|
+
* 三种现场分别处理,判据都是 shell 集成的边界,不做文本启发式:
|
|
1222
|
+
* 1. 窗口里有 `B`(命令在本次等待期间开始了)→ 只取最后一个 `B` 之后。**命令行回显在 `B` 之前**
|
|
1223
|
+
* (真机上回显与 `B` 常在同一 chunk 里、回显在前),所以这一步就把回显丢掉了;
|
|
1224
|
+
* 2. 窗口里没有 `B` 但 `inCommand`(`B` 早于注册)→ 整段都是那个命令的输出(TUI 全屏重画、
|
|
1225
|
+
* 长任务刷屏),原样返回;
|
|
1226
|
+
* 3. 都没有 → 没有集成(cmd / 集成被关)或正停在提示符上:此时可能只有提示符与回显,也可能
|
|
1227
|
+
* 是没人发命令时自己冒出来的后台输出 —— 只能交给 `commandOutputRegion` 裁掉「未闭合的
|
|
1228
|
+
* `A` 段」(提示符 + 正在回显的命令行),其余照旧返回(后台输出不能被误杀)。
|
|
1229
|
+
*/
|
|
1230
|
+
function liveOutputRegion(text, state) {
|
|
1231
|
+
const fromB = lastCommandStart(text);
|
|
1232
|
+
if (fromB !== -1)
|
|
1233
|
+
return text.slice(fromB);
|
|
1234
|
+
if (state.inCommand)
|
|
1235
|
+
return text;
|
|
1236
|
+
return commandOutputRegion(text);
|
|
1237
|
+
}
|
|
1084
1238
|
/** 首次被 agent 工具触达时把水位线落在当下——否则第一次回扫会把用户此前的历史输出当成「还没读过」。 */
|
|
1085
1239
|
function ensureReadMark(session) {
|
|
1086
1240
|
if (session.readSeq >= 0)
|
|
@@ -1097,7 +1251,8 @@ function advanceReadMark(session) {
|
|
|
1097
1251
|
* 水位线之后的**未读**原始输出。下界再抬到「最后一个 B 标记之后」:回显落在
|
|
1098
1252
|
* A(prompt 开始)与 B(命令开始)之间,所以这样能零启发式地排除回显——按
|
|
1099
1253
|
* 文本比对「跳过与发送内容相同的行」会被折行 / ANSI / 多行粘贴打碎。
|
|
1100
|
-
* 没有 B 标记(未开 shell 集成 / Windows
|
|
1254
|
+
* 没有 B 标记(未开 shell 集成 / Windows 的 cmd / 远端未装集成 / 用户关了 PSReadLine)时
|
|
1255
|
+
* 无法按边界切片:
|
|
1101
1256
|
* D75 起改用「最近提交过的输入」这份回显候选把回显行从匹配窗口里剔掉(见 echoOnlyMatch),
|
|
1102
1257
|
* 不再直接拿回显当命中。
|
|
1103
1258
|
*/
|
|
@@ -1106,7 +1261,9 @@ function unreadRegion(session) {
|
|
|
1106
1261
|
const bufferStart = session.outputSeq - session.buffer.length;
|
|
1107
1262
|
const text = session.buffer.slice(Math.max(0, session.readSeq - bufferStart));
|
|
1108
1263
|
const start = lastCommandStart(text);
|
|
1109
|
-
|
|
1264
|
+
// D101:从最后一个 B 起切只解决了「回显在 B 之前」那一半;这段里还可能有一段**还没等到 B**
|
|
1265
|
+
// 的 A 段(提示符 + 正在回显的命令行),同样要在匹配前裁掉。
|
|
1266
|
+
return commandOutputRegion(start === -1 ? text : text.slice(start));
|
|
1110
1267
|
}
|
|
1111
1268
|
/**
|
|
1112
1269
|
* pattern 匹配的统一入口(D72):先对含 ANSI 的原始流试(live 路径原语义),
|
|
@@ -1133,6 +1290,13 @@ function testPattern(re, text) {
|
|
|
1133
1290
|
* - 真实输出里也出现的标记照旧命中(不误杀);
|
|
1134
1291
|
* - 只在回显行**末尾**匹配才削(cmd 的回显与提示符同行,所以按行尾切);
|
|
1135
1292
|
* - 有 B 标记 / 命令正在跑(TUI 全屏重画里出现输入文本是正常输出)时不启用。
|
|
1293
|
+
*
|
|
1294
|
+
* **D101 补**:上面第三条让这条剔除在真实会话里几乎从不启用——窗口里出现**前一条命令**的
|
|
1295
|
+
* `B` 标记就是常态,「命令正在跑」更是常态。真机实测(2026-10-13):`Start-Sleep -Seconds 2;
|
|
1296
|
+
* echo MARK` + 等 `MARK`,Windows PowerShell 5.1 / 7 分别 446ms / 432ms 就 matched,macOS/zsh
|
|
1297
|
+
* 上 1ms。所以回显的**主防线**改成零启发式的 `commandOutputRegion`(按 `A..B` 边界裁,
|
|
1298
|
+
* `tty_expect` 的 live 路径与 `unreadRegion` 都走它);本函数退成没有标记那种壳(cmd /
|
|
1299
|
+
* 集成被关——那里没有边界可用,只能按提交过的输入做文本剔除)的兜底。
|
|
1136
1300
|
* ------------------------------------------------------------------ */
|
|
1137
1301
|
/** 回显候选的行数上限(最近几条提交的输入)与单行长度上限。 */
|
|
1138
1302
|
const ECHO_INPUT_CAP = 8;
|
|
@@ -1163,13 +1327,21 @@ function noteSubmittedInput(session, data) {
|
|
|
1163
1327
|
function echoStrippable(session, text) {
|
|
1164
1328
|
if (session.recentInputs.length === 0)
|
|
1165
1329
|
return false;
|
|
1330
|
+
/*
|
|
1331
|
+
* 命令正在跑时(D101 修正):启用与否**不能**只看 inCommand —— 真机现场是「命令已经被接受
|
|
1332
|
+
* (B 早于注册)、回显稍后才回到缓冲区」,那时精确命中命令行文本的就是回显(Windows
|
|
1333
|
+
* PowerShell 上 432ms 就 matched,而命令还在 Start-Sleep)。
|
|
1334
|
+
*
|
|
1335
|
+
* 判据换成「最近一次提交**早于本条命令的开始**」:那是命令行本身(回显就该剔掉);提交晚于
|
|
1336
|
+
* 命令开始的,是往在跑的程序里敲的键 —— 它在屏上出现属于**真实输出**,不许剔(D75 的边界)。
|
|
1337
|
+
*/
|
|
1166
1338
|
if (session.shellState.inCommand)
|
|
1167
|
-
return
|
|
1339
|
+
return session.lastInputAt < session.shellState.commandStartedAt;
|
|
1168
1340
|
return lastCommandStart(text) === -1;
|
|
1169
1341
|
}
|
|
1170
1342
|
/**
|
|
1171
1343
|
* 把回显候选行从文本里削掉(保行结构)。按**行尾**匹配:cmd 的回显与提示符同行
|
|
1172
|
-
* (`C:\>echo X
|
|
1344
|
+
* (`C:\>echo X`),所以只削后缀、保留提示符前缀。行尾空白与控制字节先归一。
|
|
1173
1345
|
*/
|
|
1174
1346
|
function stripEchoLines(text, echoes) {
|
|
1175
1347
|
if (echoes.length === 0)
|
|
@@ -1178,7 +1350,12 @@ function stripEchoLines(text, echoes) {
|
|
|
1178
1350
|
return text
|
|
1179
1351
|
.split('\n')
|
|
1180
1352
|
.map((line) => {
|
|
1181
|
-
|
|
1353
|
+
/*
|
|
1354
|
+
* 行尾的空白与**控制字节**都算噪音(D101 真机实测):zsh 在命令行后面紧跟着写
|
|
1355
|
+
* bracketed-paste 开关 `\e[?2004l`,不把它剥掉,「按行尾削」就永远削不掉那一行——
|
|
1356
|
+
* 于是回显被当成真输出(macOS/zsh 上实测 1ms 命中)。
|
|
1357
|
+
*/
|
|
1358
|
+
const trimmed = line.replace(/[\s\x00-\x1f\x7f]+$/, '');
|
|
1182
1359
|
for (const echo of sorted) {
|
|
1183
1360
|
if (trimmed.endsWith(echo))
|
|
1184
1361
|
return trimmed.slice(0, trimmed.length - echo.length);
|
|
@@ -1189,14 +1366,16 @@ function stripEchoLines(text, echoes) {
|
|
|
1189
1366
|
}
|
|
1190
1367
|
/**
|
|
1191
1368
|
* 命中是否**只**落在回显上(纯回显 → 不算命中)。
|
|
1192
|
-
*
|
|
1369
|
+
*
|
|
1370
|
+
* D101 起只看**清洗后**的文本:原来原始流与清洗文本各削一次、要求「两边都不再命中」才算回显,
|
|
1371
|
+
* 于是原始流里跟在命令行后面的转义序列(`\e[?2004l`、zle 的光标移动……)让 raw 变体削不干净、
|
|
1372
|
+
* 照样命中 → 整条判据失效。清洗后的文本才是「agent 真正看得到的那一份」(`tty_capture` 与
|
|
1373
|
+
* `tty_expect` 返回的也是它),按它判定既够用又不自相矛盾。
|
|
1193
1374
|
*/
|
|
1194
1375
|
function echoOnlyMatch(re, text, session) {
|
|
1195
1376
|
if (!echoStrippable(session, text))
|
|
1196
1377
|
return false;
|
|
1197
|
-
|
|
1198
|
-
const strippedClean = stripEchoLines(cleanAnsiTail(text), session.recentInputs);
|
|
1199
|
-
return !testPattern(re, stripped) && !testPattern(re, strippedClean);
|
|
1378
|
+
return !testPattern(re, stripEchoLines(cleanAnsiTail(text), session.recentInputs));
|
|
1200
1379
|
}
|
|
1201
1380
|
/** 宽松清洗一份 tunnels 输入;输入不是数组时返回 undefined(表示「未提供,保持原值」)。 */
|
|
1202
1381
|
function sanitizeTunnels(input) {
|
|
@@ -3727,20 +3906,21 @@ const plugin = definePlugin({
|
|
|
3727
3906
|
const config = plainConfig((rawConfig ?? {}));
|
|
3728
3907
|
if (config?.enabled === false)
|
|
3729
3908
|
return;
|
|
3909
|
+
// 默认 shell 按平台取(Windows 没有 $SHELL,回落 /bin/zsh 会让本地终端一条都开不起来;
|
|
3910
|
+
// 见 defaultShellPath)。用户显式填了「Shell 路径」就照用。
|
|
3911
|
+
const configuredShell = config?.shell?.trim() || defaultShellPath();
|
|
3730
3912
|
const live = new LiveConfig({
|
|
3731
|
-
|
|
3732
|
-
// 见 defaultShellPath)。用户显式填了「Shell 路径」就照用。
|
|
3733
|
-
shell: config?.shell?.trim() || defaultShellPath(),
|
|
3913
|
+
shell: configuredShell,
|
|
3734
3914
|
term: config?.term?.trim() || 'xterm-256color',
|
|
3735
3915
|
colorTerm: config?.colorTerm?.trim() || 'truecolor',
|
|
3736
3916
|
cwd: config?.cwd?.trim() || process.cwd(),
|
|
3737
3917
|
reconnectGraceSec: typeof config?.reconnectGraceSec === 'number' && Number.isInteger(config.reconnectGraceSec) && config.reconnectGraceSec >= 0 ? config.reconnectGraceSec : DEFAULT_RECONNECT_GRACE_SEC,
|
|
3738
3918
|
sshHosts: Array.isArray(config?.sshHosts) ? config.sshHosts : [],
|
|
3739
3919
|
hostKeys: Array.isArray(config?.hostKeys) ? config.hostKeys : [],
|
|
3740
|
-
// shell 集成(OSC 133/7
|
|
3741
|
-
//
|
|
3742
|
-
//
|
|
3743
|
-
shellIntegration:
|
|
3920
|
+
// shell 集成(OSC 133/7):POSIX 走 rc 注入 + `-c` 包装层,Windows 走 PowerShell 的
|
|
3921
|
+
// `-File` 桩(见 buildWindowsShellSpawn)。Windows + cmd 仍是 false —— 没有命令开始/
|
|
3922
|
+
// 结束钩子,硬开只会让界面摆一个不生效的开关。
|
|
3923
|
+
shellIntegration: config?.shellIntegration !== false && shellIntegrationSupported(configuredShell),
|
|
3744
3924
|
tunnels: Array.isArray(config?.tunnels) ? config.tunnels : [],
|
|
3745
3925
|
persistence: config?.persistence === 'tmux' ? 'tmux' : 'off',
|
|
3746
3926
|
endOnPageClose: config?.endOnPageClose === true,
|
|
@@ -3839,6 +4019,7 @@ const plugin = definePlugin({
|
|
|
3839
4019
|
hostKeys: live.hostKeys,
|
|
3840
4020
|
tunnels: live.tunnels,
|
|
3841
4021
|
shellIntegration: live.shellIntegration,
|
|
4022
|
+
shellIntegrationSupported: shellIntegrationSupported(live.shell),
|
|
3842
4023
|
sftpStyle: stateRef.sftpStyle,
|
|
3843
4024
|
persistence: live.persistence,
|
|
3844
4025
|
endOnPageClose: live.endOnPageClose,
|
|
@@ -4978,7 +5159,7 @@ const plugin = definePlugin({
|
|
|
4978
5159
|
name: 'tty_list',
|
|
4979
5160
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
4980
5161
|
isConcurrencySafe: () => true,
|
|
4981
|
-
description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs)。**running** 说明这个会话有没有命令在跑(true = 在跑,现在别往里发命令,那些字节会被正在跑的程序当输入吃掉;false = 命令已结束;**省略 = 无法判断**——该会话没有 shell 集成标记,例如非持久 SSH / fish·csh / Windows
|
|
5162
|
+
description: '列出当前终端面板会话(sid / kind(local|ssh) / target / pid / cwd / 创建与最后活动时间),**含进程已退出但仍只读保留着的会话**(带 exited:true + 退出码/信号;按时间释放时另带 retainMs)。**running** 说明这个会话有没有命令在跑(true = 在跑,现在别往里发命令,那些字节会被正在跑的程序当输入吃掉;false = 命令已结束;**省略 = 无法判断**——该会话没有 shell 集成标记,例如非持久 SSH / fish·csh / Windows 的 cmd / 集成被关,**不是**「没在跑」);同源的 lastExitCode / lastExitAt 是「上一条已完成命令」的退出码与结束时刻。用户开了终端面板后,用 tty_capture 读取某个 sid 的输出、用 tty_send 向该会话发送按键。已退出的会话只能读(写会报错),要接着操作请 tty_open 新开一条。',
|
|
4982
5163
|
parameters: {},
|
|
4983
5164
|
output: {
|
|
4984
5165
|
schema: {
|
|
@@ -5518,7 +5699,10 @@ const plugin = definePlugin({
|
|
|
5518
5699
|
// 的收窄,闭包里就得再判一次 undefined。
|
|
5519
5700
|
const onData = (chunk) => {
|
|
5520
5701
|
acc = (acc + decoder.write(chunk)).slice(-64 * 1024);
|
|
5521
|
-
|
|
5702
|
+
// D101:先按 shell 集成的命令边界裁掉提示符/回显段(见 liveOutputRegion),
|
|
5703
|
+
// 再取 16KB 尾部窗口。反过来(先切 16KB 再裁)会切掉段首那个 B,回显又漏回窗口里。
|
|
5704
|
+
const region = liveOutputRegion(acc, state);
|
|
5705
|
+
const hay = region.length > 16 * 1024 ? region.slice(-16 * 1024) : region;
|
|
5522
5706
|
if (testPattern(re, hay)) {
|
|
5523
5707
|
// D75:命中的若**只是刚送进去那行命令的回显**,不算命中——继续等真输出
|
|
5524
5708
|
if (echoOnlyMatch(re, hay, session))
|