@xiaoqiong0v0/opencode-ssh-tool 0.6.15 → 0.6.16
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/dist/agent.d.ts +4 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +17 -2
- package/dist/agent.js.map +1 -1
- package/dist/base-session.d.ts +64 -32
- package/dist/base-session.d.ts.map +1 -1
- package/dist/base-session.js +235 -106
- package/dist/base-session.js.map +1 -1
- package/dist/constants.d.ts +7 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +7 -1
- package/dist/constants.js.map +1 -1
- package/dist/i18n.d.ts +20 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +20 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -4
- package/dist/index.js.map +1 -1
- package/dist/local-session.d.ts +3 -1
- package/dist/local-session.d.ts.map +1 -1
- package/dist/local-session.js +82 -4
- package/dist/local-session.js.map +1 -1
- package/dist/log.d.ts +8 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +28 -1
- package/dist/log.js.map +1 -1
- package/dist/parse-check.d.ts +20 -0
- package/dist/parse-check.d.ts.map +1 -0
- package/dist/parse-check.js +505 -0
- package/dist/parse-check.js.map +1 -0
- package/dist/precheck.d.ts +32 -0
- package/dist/precheck.d.ts.map +1 -0
- package/dist/precheck.js +105 -0
- package/dist/precheck.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +41 -5
- package/dist/server.js.map +1 -1
- package/dist/session-store.d.ts +33 -0
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +55 -0
- package/dist/session-store.js.map +1 -1
- package/dist/session.js +1 -1
- package/dist/session.js.map +1 -1
- package/dist/shell-adapter.d.ts +14 -0
- package/dist/shell-adapter.d.ts.map +1 -1
- package/dist/shell-adapter.js +58 -10
- package/dist/shell-adapter.js.map +1 -1
- package/dist/utils.d.ts +8 -34
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +22 -64
- package/dist/utils.js.map +1 -1
- package/dist/web/app.js +1 -1
- package/package.json +1 -1
package/dist/base-session.js
CHANGED
|
@@ -1,48 +1,98 @@
|
|
|
1
1
|
// BaseSession:SSH(SshSession) 与本地(LocalSession) 终端的公共基类
|
|
2
|
-
//
|
|
2
|
+
// 统一完成标记法:每条命令后追加一条标记命令,完成后输出 <SSH_DONE:nonce:seq:退出码>(不依赖 PROMPT_COMMAND/PS1 钩子)
|
|
3
3
|
// 子类仅需实现:连接建立(connect)、数据写入(_write)、传输层清理(_closeTransport)、状态字段(getStatus)
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { ANIMATION_WINDOW_MS, MAX_OUTPUT_LEN, RAW_LOG_MAX, DONE_TAG, SYNTAX_QUIET_MS, SYNTAX_MAX_WAIT_MS, FALLBACK_WATCH_INTERVAL_MS, INTERRUPT_PROBE_INTERVAL_DEFAULT_SEC, } from "./constants.js";
|
|
5
|
+
import { ANIMATION_WINDOW_MS, MAX_OUTPUT_LEN, RAW_LOG_MAX, DONE_TAG, PROBE_TAG, SYNTAX_QUIET_MS, SYNTAX_MAX_WAIT_MS, FALLBACK_WATCH_INTERVAL_MS, INTERRUPT_PROBE_INTERVAL_DEFAULT_SEC, } from "./constants.js";
|
|
6
6
|
import log, { debug, describeSend, infoDedup, isDebugEnabled, tailLines, tailSummary } from "./log.js";
|
|
7
7
|
import { decodeTerminalEscapes } from "./escapes.js";
|
|
8
8
|
import { SessionHistory } from "./history.js";
|
|
9
|
-
import { toModelText, extractOutputStart,
|
|
10
|
-
import { detectLastDoneMarker, stripMarkers, detectInterrupt, detectSyntaxError, adapters } from "./shell-adapter.js";
|
|
9
|
+
import { toModelText, extractOutputStart, removeProbeEchoLines, ECHO_REGEX_MAX_CMD_CHARS, echoRegexSourceLength } from "./utils.js";
|
|
10
|
+
import { detectLastDoneMarker, detectLastProbeMarker, stripMarkers, detectInterrupt, detectSyntaxError, adapters } from "./shell-adapter.js";
|
|
11
11
|
import { tr } from "./i18n.js";
|
|
12
12
|
/** buffer 最大长度(未消费输出超限时截断头部,防内存膨胀) */
|
|
13
13
|
const MAX_BUFFER_LEN = 2 * 1024 * 1024;
|
|
14
14
|
/** Shell 探测等待超时 */
|
|
15
15
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
16
|
+
/** 探针回显 seq 保留上限:只保留最近 N 条,防长会话 Set 无界增长与剔除成本 O(N×行) */
|
|
17
|
+
const PROBE_SEQ_KEEP = 8;
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* 剥离命令尾部注释:只在**最后一行**、`#` 位于引号外且处于词首时视为注释。
|
|
20
|
+
* 扫描整条命令以维护跨行引号状态(bash/pwsh 字符串都可跨行),避免 `"line2 # x"` 被误截断。
|
|
21
|
+
* - pwsh:反引号是转义符;单引号内 `''` 表示字面单引号。
|
|
22
|
+
* - bash/zsh:反斜杠转义;反引号是命令替换,内部 `#` 不剥(保守处理)。
|
|
23
|
+
* - cmd:不支持 `#` 注释,跳过。
|
|
24
|
+
* @param s 完整命令文本
|
|
25
|
+
* @param shell 适配器名(bash/zsh/pwsh/cmd;空串按 POSIX 处理)
|
|
20
26
|
* @returns 剥离尾注释(若有)后的文本
|
|
21
27
|
*/
|
|
22
|
-
function stripTrailingComment(s) {
|
|
28
|
+
function stripTrailingComment(s, shell) {
|
|
29
|
+
if (shell === "cmd")
|
|
30
|
+
return s.trimEnd();
|
|
31
|
+
const pwsh = shell === "pwsh" || shell === "powershell";
|
|
32
|
+
const lastLineStart = s.lastIndexOf("\n") + 1;
|
|
23
33
|
let inSingle = false;
|
|
24
34
|
let inDouble = false;
|
|
25
|
-
let
|
|
35
|
+
let inBacktick = false;
|
|
26
36
|
for (let i = 0; i < s.length; i++) {
|
|
27
37
|
const c = s[i];
|
|
28
|
-
if (
|
|
29
|
-
|
|
38
|
+
if (c === "\n")
|
|
39
|
+
continue;
|
|
40
|
+
if (inSingle) {
|
|
41
|
+
if (pwsh && c === "'" && s[i + 1] === "'") {
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (c === "'")
|
|
46
|
+
inSingle = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inDouble) {
|
|
50
|
+
if (pwsh && c === "`") {
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!pwsh && c === "\\") {
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === '"')
|
|
59
|
+
inDouble = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (inBacktick) {
|
|
63
|
+
if (c === "`")
|
|
64
|
+
inBacktick = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (pwsh && c === "`") {
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!pwsh && c === "\\") {
|
|
72
|
+
i++;
|
|
30
73
|
continue;
|
|
31
74
|
}
|
|
32
|
-
if (c === "
|
|
33
|
-
|
|
75
|
+
if (c === "'") {
|
|
76
|
+
inSingle = true;
|
|
34
77
|
continue;
|
|
35
78
|
}
|
|
36
|
-
if (c === "'
|
|
37
|
-
|
|
79
|
+
if (c === '"') {
|
|
80
|
+
inDouble = true;
|
|
38
81
|
continue;
|
|
39
82
|
}
|
|
40
|
-
if (c ===
|
|
41
|
-
|
|
83
|
+
if (!pwsh && c === "`") {
|
|
84
|
+
inBacktick = true;
|
|
42
85
|
continue;
|
|
43
86
|
}
|
|
44
|
-
|
|
45
|
-
|
|
87
|
+
// 注释起始:行首/空白/语句分隔符/括号之后(`;#`、`&#`、`)#` 都能正确识别)
|
|
88
|
+
if (c === "#" && (i === 0 || /[\s;&|(){}]/.test(s[i - 1]))) {
|
|
89
|
+
if (i >= lastLineStart)
|
|
90
|
+
return s.slice(0, i).trimEnd();
|
|
91
|
+
// 前面的行上的注释:跳过本行剩余内容,继续扫描后续行
|
|
92
|
+
const nl = s.indexOf("\n", i);
|
|
93
|
+
if (nl < 0)
|
|
94
|
+
break;
|
|
95
|
+
i = nl;
|
|
46
96
|
}
|
|
47
97
|
}
|
|
48
98
|
return s.trimEnd();
|
|
@@ -52,16 +102,17 @@ function stripTrailingComment(s) {
|
|
|
52
102
|
* - 尾注释 `# xxx` 把 marker 整行吞掉 → 检测不到完成
|
|
53
103
|
* - 尾运算符 `&`、`;`、`&&`、`||` 与拼接的 `;` 形成 `&;`/`;;` 等语法错误
|
|
54
104
|
* @param command 原始命令
|
|
105
|
+
* @param shell 适配器名(用于正确的引号/转义语义;空串按 POSIX 处理)
|
|
55
106
|
* @returns 可安全拼装 marker 的命令体
|
|
56
107
|
*/
|
|
57
|
-
function stripCommandTail(command) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return (head +
|
|
108
|
+
export function stripCommandTail(command, shell = "") {
|
|
109
|
+
const withoutComment = stripTrailingComment(command, shell);
|
|
110
|
+
// 仅处理最后一行:多行命令(如 heredoc 正文)中间的 `#`/运算符是内容,不能在这里截断
|
|
111
|
+
const nl = withoutComment.lastIndexOf("\n");
|
|
112
|
+
const head = nl >= 0 ? withoutComment.slice(0, nl + 1) : "";
|
|
113
|
+
const last = nl >= 0 ? withoutComment.slice(nl + 1) : withoutComment;
|
|
114
|
+
const cleaned = last.replace(/(?:&&|\|\||[;&|])[\s]*$/, "");
|
|
115
|
+
return (head + cleaned).trimEnd();
|
|
65
116
|
}
|
|
66
117
|
/**
|
|
67
118
|
* 诊断辅助:转义控制字符,使日志单行可读(\r\n\t 转义、ESC 写成 \x1b)。
|
|
@@ -150,11 +201,6 @@ export class BaseSession {
|
|
|
150
201
|
* 只认自己 nonce:重放标记不再冒充本会话标记。
|
|
151
202
|
*/
|
|
152
203
|
_nonce = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
153
|
-
/**
|
|
154
|
-
* 命令发起前缓冲的末行(旧提示符行):语法错误收尾时 shell 会重印同形提示符,
|
|
155
|
-
* 据此把它从输出尾部剥掉(exec 的输出窗口自命令回显起,旧提示符在窗口之外,需单独记录)。
|
|
156
|
-
*/
|
|
157
|
-
_promptRef = "";
|
|
158
204
|
/** 下次捕获窗口起点(相对当前 buffer):前一条命令结束后,标记/残留从此处开始 */
|
|
159
205
|
_cursor = 0;
|
|
160
206
|
/** WS 实时流已发送位置(相对 buffer 偏移);null = 尚未定位命令回显结束点 */
|
|
@@ -163,6 +209,12 @@ export class BaseSession {
|
|
|
163
209
|
_adapter = null;
|
|
164
210
|
/** 多行命令写入 PTY 的行分隔符:Unix PTY 用 \n;Windows ConPTY(本地会话)用 \r(裸 \n 不提交行) */
|
|
165
211
|
_lineSep = "\n";
|
|
212
|
+
/**
|
|
213
|
+
* 当前会话终端是否为 Windows ConPTY:决定提交分隔符。
|
|
214
|
+
* 本地会话按宿主平台设置;SSH 会话在 shell 探测后按 pwsh 的 `$PSHOME` 路径判断
|
|
215
|
+
*(Windows `C:\...` → true,Unix `/...` → false),不能只看 adapter 类型。
|
|
216
|
+
*/
|
|
217
|
+
_windowsPTY = process.platform === "win32";
|
|
166
218
|
_closed = false;
|
|
167
219
|
/** 当前运行命令的输入时刻(history 记录展示命令发起时间用) */
|
|
168
220
|
_runningStartTs = 0;
|
|
@@ -351,6 +403,14 @@ export class BaseSession {
|
|
|
351
403
|
* @returns 8 位十六进制会话串
|
|
352
404
|
*/
|
|
353
405
|
get nonce() { return this._nonce; }
|
|
406
|
+
/**
|
|
407
|
+
* 当前 shell 适配器名("zsh" / "bash" / "pwsh" / "cmd"):供发送前语法预检选校验器。
|
|
408
|
+
* 只读暴露探测结果,不触发探测(未连接时为 null,调用方按"不校验"处理)。
|
|
409
|
+
* @returns 适配器名;未连接/未探测到适配器时为 null
|
|
410
|
+
*/
|
|
411
|
+
get shellName() { return this._adapter?.name ?? null; }
|
|
412
|
+
/** 是否本地会话:发送前语法预检只接本地(SSH 远程需会话内往返,另开一轮) */
|
|
413
|
+
get isLocal() { return false; }
|
|
354
414
|
getRunningOutput() {
|
|
355
415
|
if (this._runningStartPos === null || !this._connected || !this._runningCommand)
|
|
356
416
|
return "";
|
|
@@ -475,7 +535,10 @@ export class BaseSession {
|
|
|
475
535
|
this._closed = true;
|
|
476
536
|
if (this._watchTimer)
|
|
477
537
|
clearInterval(this._watchTimer);
|
|
478
|
-
this.
|
|
538
|
+
this._watchTimer = null;
|
|
539
|
+
// 先清运行上下文(含语法/中断定时器),再释放 history:
|
|
540
|
+
// 否则 close 后定时器仍可能触发写已关闭终端,并在 history 已 dispose 时 append 抛未捕获 ENOENT
|
|
541
|
+
this._clearRunningContext();
|
|
479
542
|
this._closeTransport();
|
|
480
543
|
this._connected = false;
|
|
481
544
|
this._setBusy(false, "会话关闭");
|
|
@@ -496,28 +559,39 @@ export class BaseSession {
|
|
|
496
559
|
_markerCmd(seq) {
|
|
497
560
|
return this._adapter ? this._adapter.markerCmd(this._nonce, seq) : `printf '\\n${DONE_TAG}${this._nonce}:${seq}:%s>' $?`;
|
|
498
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* 补发探针命令:与完成标记命令**同构**,仅把 tag 换成 PROBE_TAG。
|
|
564
|
+
* 复用各 adapter 的 markerCmd,避免四套 shell 各维护一份退出码表达式;
|
|
565
|
+
* 独立 tag 让「探针标记」与「原命令真实完成标记」在字节流里可严格区分。
|
|
566
|
+
* @param seq 命令序号
|
|
567
|
+
* @returns 探针标记命令文本
|
|
568
|
+
*/
|
|
569
|
+
_probeCmd(seq) {
|
|
570
|
+
const marker = this._markerCmd(seq);
|
|
571
|
+
// cmd 的 marker 用 ^< 转义,DONE_TAG 不再是连续子串;此时只替换 tag 主体
|
|
572
|
+
return marker.includes(DONE_TAG) ? marker.replace(DONE_TAG, PROBE_TAG) : marker.replace("SSH_DONE:", "SSH_PROBE:");
|
|
573
|
+
}
|
|
499
574
|
/**
|
|
500
575
|
* 组合实际写入 PTY 的命令:在规范形式(`_composeEchoText`)基础上把换行改写为行分隔符并补尾部回车。
|
|
501
|
-
*
|
|
502
|
-
* - Windows
|
|
503
|
-
* -
|
|
576
|
+
* 行分隔符按**探测到的终端平台**决定,而非按传输类型(本地/SSH):
|
|
577
|
+
* - Windows ConPTY(本机 Windows,或 SSH 到 Windows pwsh/cmd)→ `\r`;
|
|
578
|
+
* - Unix PTY(bash/zsh,含 Linux/macOS 上的 pwsh)→ `_lineSep`(通常 `\n`)。
|
|
579
|
+
* SSH 的 pwsh 平台由探测输出里的 `$PSHOME` 路径区分(`C:\...` vs `/...`)。
|
|
504
580
|
* 原因:ConPTY 下裸 `\n` 只下移不复位、不提交行 → 多行命令逆序执行并卡在 `>>` 续行,须用 `\r`;
|
|
505
|
-
*
|
|
506
|
-
* Unix PTY 下 `\n` 可正常提交行,保持不变。
|
|
581
|
+
* Unix PTY 下 `\n` 可正常提交行。
|
|
507
582
|
* @param command 原始命令(history 存干净版本)
|
|
508
|
-
* @returns 实际写入 PTY
|
|
583
|
+
* @returns 实际写入 PTY 的文本(尾部带提交分隔符,行分隔已按平台规范化)
|
|
509
584
|
*/
|
|
510
585
|
_composeCommand(command) {
|
|
511
|
-
//
|
|
512
|
-
return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) +
|
|
586
|
+
// 行分隔符按终端平台判定(见 _ptyLineSep)
|
|
587
|
+
return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) + this._ptyLineSep();
|
|
513
588
|
}
|
|
514
589
|
/**
|
|
515
|
-
*
|
|
516
|
-
* (裸 \n 不提交行);其余沿用会话 `_lineSep`(Unix PTY 为 \n,本地会话为 \r)。
|
|
590
|
+
* 当前终端的 PTY **提交分隔符**:Windows ConPTY → `\r`;Unix PTY → `_lineSep`(通常 `\n`)。
|
|
517
591
|
* @returns 提交分隔符字符串
|
|
518
592
|
*/
|
|
519
593
|
_ptyLineSep() {
|
|
520
|
-
return this.
|
|
594
|
+
return this._windowsPTY ? "\r" : this._lineSep;
|
|
521
595
|
}
|
|
522
596
|
/**
|
|
523
597
|
* 组合中断探针写入文本:**不加任何前缀换行**,直接写一条独立的当前 seq 完成标记命令,
|
|
@@ -532,26 +606,39 @@ export class BaseSession {
|
|
|
532
606
|
* @returns 写入 PTY 的探针文本
|
|
533
607
|
*/
|
|
534
608
|
_composeProbe(seq) {
|
|
535
|
-
return this.
|
|
609
|
+
return this._probeCmd(seq) + this._ptyLineSep();
|
|
610
|
+
}
|
|
611
|
+
/** 记录探针 seq,只保留最近 PROBE_SEQ_KEEP 条(迟到回显跨命令有限,避免 Set 无界增长) */
|
|
612
|
+
_rememberProbeSeq(seq) {
|
|
613
|
+
this._probeSeqs.add(seq);
|
|
614
|
+
while (this._probeSeqs.size > PROBE_SEQ_KEEP) {
|
|
615
|
+
const first = this._probeSeqs.values().next().value;
|
|
616
|
+
if (first === undefined)
|
|
617
|
+
break;
|
|
618
|
+
this._probeSeqs.delete(first);
|
|
619
|
+
}
|
|
536
620
|
}
|
|
537
621
|
/**
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
622
|
+
* 补发一次当前命令的探针标记(仅中断收尾使用):
|
|
623
|
+
* 写入一条独立的本 seq 标记命令,并记录 seq,使其回显迟到落入后续窗口时仍能被剔除。
|
|
624
|
+
* - 中断场景:用户显式 Ctrl+C 后前台命令可能已死、原命令的完成标记永不执行 → busy 卡死;
|
|
625
|
+
* 补写探针后 shell 若已回到提示符会读它并打印当前 seq 标记使命令收尾;若前台程序未死,
|
|
626
|
+
* 字节留在 tty 输入缓冲待 shell 拿回控制权再读(两种情况均符合预期,绝不误判完成)。
|
|
627
|
+
* 由中断周期调用:写完 \x03 时立即补发一次,之后定时器每间隔补发一次(见 _startInterruptCycle)。
|
|
628
|
+
* 幂等:同一条命令多次补发时,先到的当前 seq 标记胜出。仅在确实追踪着命令时补发(空闲补发只污染缓冲)。
|
|
629
|
+
* 注意:语法错误路径**不再补发探针**(会写进仍在运行程序的 stdin),改为安静窗口到点直接收尾。
|
|
630
|
+
* @param reason 补发原因(仅用于日志:中断探针)
|
|
545
631
|
* @returns 无
|
|
546
632
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
633
|
+
_sendProbe(reason) {
|
|
634
|
+
if (!this._connected)
|
|
635
|
+
return;
|
|
549
636
|
if (this._runningStartPos === null || this._runningCommand === "")
|
|
550
637
|
return;
|
|
551
638
|
const seq = this._runningSeq;
|
|
552
|
-
this.
|
|
639
|
+
this._rememberProbeSeq(seq);
|
|
553
640
|
const probe = this._composeProbe(seq);
|
|
554
|
-
debug(
|
|
641
|
+
debug(`${reason} ${this._logTag} seq=${seq} ${JSON.stringify(probe)} (busy=${this._remoteBusy})`);
|
|
555
642
|
this._write(probe);
|
|
556
643
|
}
|
|
557
644
|
/**
|
|
@@ -573,7 +660,7 @@ export class BaseSession {
|
|
|
573
660
|
const intervalSec = this._interruptProbeIntervalMs / 1000;
|
|
574
661
|
log.info(`中断周期开始 ${this._logTag} seq=${seq},已立即补发首条探针,之后每 ${intervalSec}s 一条`);
|
|
575
662
|
// 写完 \x03 后立即补发首条探针(t0),后续 setInterval 自此刻起按间隔补发(t0+N、t0+2N…)
|
|
576
|
-
this.
|
|
663
|
+
this._sendProbe("中断探针");
|
|
577
664
|
this._interruptTimer = setInterval(() => { this._onInterruptTick(); }, this._interruptProbeIntervalMs);
|
|
578
665
|
}
|
|
579
666
|
/**
|
|
@@ -586,7 +673,7 @@ export class BaseSession {
|
|
|
586
673
|
this._stopInterruptCycle("命令已结束或会话关闭");
|
|
587
674
|
return;
|
|
588
675
|
}
|
|
589
|
-
this.
|
|
676
|
+
this._sendProbe("中断探针");
|
|
590
677
|
}
|
|
591
678
|
/**
|
|
592
679
|
* 停止中断周期并清周期状态:清定时器,日志记录结束原因(幂等,未在周期内时不做任何事)。
|
|
@@ -602,22 +689,24 @@ export class BaseSession {
|
|
|
602
689
|
log.info(`中断周期结束 ${this._logTag} seq=${this._runningSeq},原因=${reason}`);
|
|
603
690
|
}
|
|
604
691
|
/**
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
692
|
+
* 从可见输出中剔除探针留下的痕迹(探针命令的回显)。
|
|
693
|
+
* 探针命令已改为独立 PROBE tag,但其回显仍是"一条标记命令";同时保留对当前命令 DONE 标记回显的剔除。
|
|
694
|
+
* 对每个已记录探针 seq(以及当前命令自身)做 **ANSI 容忍定位**:即便 pwsh 语法高亮在 token 间插入
|
|
608
695
|
* SGR、行首带提示符与 `\x1b[?25l`,也能命中并整段剔除(见 removeProbeEchoLines)。
|
|
609
|
-
* 三处调用点(本函数、
|
|
610
|
-
* 统一收敛至此,消除口径漂移。Raw 视图不经此函数,保留原始字节。
|
|
696
|
+
* 三处调用点(本函数、getRunningStream、readBuffer)统一收敛至此;Raw 视图保留原始字节。
|
|
611
697
|
* @param text 可见输出文本
|
|
612
698
|
* @returns 剔除探针回显后的文本
|
|
613
699
|
*/
|
|
614
700
|
_stripProbeEchoes(text) {
|
|
615
|
-
const needles =
|
|
616
|
-
for (const seq of this._probeSeqs)
|
|
617
|
-
needles.
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
701
|
+
const needles = new Set();
|
|
702
|
+
for (const seq of this._probeSeqs) {
|
|
703
|
+
needles.add(this._markerCmd(seq));
|
|
704
|
+
needles.add(this._probeCmd(seq));
|
|
705
|
+
}
|
|
706
|
+
// 当前命令的标记命令回显:与上面可能重复,Set 去重避免同一 needle 反复扫描
|
|
707
|
+
needles.add(this._markerCmd(this._runningSeq));
|
|
708
|
+
needles.add(this._probeCmd(this._runningSeq));
|
|
709
|
+
return removeProbeEchoLines(text, [...needles]);
|
|
621
710
|
}
|
|
622
711
|
/**
|
|
623
712
|
* 组合命令文本(规范/匹配用形式,始终以 `\n` 分隔行、无尾部回车):命令 + 完成标记。
|
|
@@ -638,8 +727,11 @@ export class BaseSession {
|
|
|
638
727
|
*/
|
|
639
728
|
_composeEchoText(command) {
|
|
640
729
|
const marker = this._markerCmd(this._runningSeq);
|
|
641
|
-
const body = stripCommandTail(command);
|
|
730
|
+
const body = stripCommandTail(command, this._adapter?.name ?? "");
|
|
642
731
|
const sep = this._adapter?.stmtSep ?? ";";
|
|
732
|
+
// cmd:marker 必须独占下一行——%errorlevel% 是整行解析期展开,同行拼接会取到旧值
|
|
733
|
+
if (this._adapter?.name === "cmd")
|
|
734
|
+
return `${body}\n${marker}`;
|
|
643
735
|
// 多行命令:尝试包组(cmd 返回 null → 回退原样拼接),使整段作为一条命令执行
|
|
644
736
|
if (body.includes("\n")) {
|
|
645
737
|
const grouped = this._adapter?.groupWrap(body);
|
|
@@ -656,7 +748,7 @@ export class BaseSession {
|
|
|
656
748
|
* 语法错误路径(syntaxMode)另作两处特殊处理:
|
|
657
749
|
* - 回显定位取**首次**匹配:pwsh 错误块的 ` 1 | <命令>` 行会重印含标记的整行命令,
|
|
658
750
|
* 末次匹配会落到该拷贝上,导致 `ParserError:`/`Line |` 等错误块前半被裁掉;
|
|
659
|
-
* -
|
|
751
|
+
* - 输出窗口右界取缓冲区末尾(不再补发语法探针),尾部最多多 1 行提示符噪音。
|
|
660
752
|
* @param raw 本次执行窗口原始字节流
|
|
661
753
|
* @param command 原始命令(历史用)
|
|
662
754
|
* @param syntaxMode true 表示语法/解析错误收尾(无完成标记,整行命令作废)
|
|
@@ -665,6 +757,10 @@ export class BaseSession {
|
|
|
665
757
|
_extractOutput(raw, command, syntaxMode = false) {
|
|
666
758
|
// 完成标记先定位:marker 自带唯一 seq,从窗口起点搜索即可(echoEnd 可能被连续输入的后序回显带偏)
|
|
667
759
|
const marker = detectLastDoneMarker(raw, 0, this._nonce, this._runningSeq);
|
|
760
|
+
// 中断探针路径没有真实完成标记,右界取 PROBE 标记结束位置,避免把探针之后的提示符污染带进输出;
|
|
761
|
+
// 语法错误路径不补发探针,boundary 退到 raw.length(尾部最多多 1 行提示符噪音)
|
|
762
|
+
const probe = marker.done ? null : detectLastProbeMarker(raw, 0, this._nonce, this._runningSeq);
|
|
763
|
+
const boundary = marker.done ? marker.pos : (probe?.done ? probe.pos : raw.length);
|
|
668
764
|
const echoText = this._composeEchoText(command);
|
|
669
765
|
const echoLen = echoText.trim().length;
|
|
670
766
|
// 超长回显:extractOutputStart 会降级为"首/尾锚点"匹配(否则模式源约 265×长度,JSC 下会抛 too large)
|
|
@@ -673,9 +769,7 @@ export class BaseSession {
|
|
|
673
769
|
}
|
|
674
770
|
let end;
|
|
675
771
|
try {
|
|
676
|
-
end =
|
|
677
|
-
? extractOutputStart(raw, echoText, marker.pos)
|
|
678
|
-
: extractOutputStart(raw, echoText, raw.length, syntaxMode);
|
|
772
|
+
end = extractOutputStart(raw, echoText, boundary, syntaxMode);
|
|
679
773
|
}
|
|
680
774
|
catch (e) {
|
|
681
775
|
// 回显定位异常绝不上抛(否则会中断完成收尾):记一行后退回整窗输出
|
|
@@ -684,7 +778,7 @@ export class BaseSession {
|
|
|
684
778
|
}
|
|
685
779
|
// 诊断:输出窗口坐标 + 是否退回整窗(startPos=回显结束偏移,endPos=窗口末尾;窗口长=endPos-startPos)
|
|
686
780
|
const winStart = end <= 0 ? 0 : end;
|
|
687
|
-
const winEnd =
|
|
781
|
+
const winEnd = boundary;
|
|
688
782
|
debug(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口: startPos=${winStart} endPos=${winEnd} 窗口长=${winEnd - winStart} markerSeq=${this._runningSeq} 是否退回整窗=${end <= 0 ? `是(定位失败, syntaxMode=${syntaxMode})` : "否"}`);
|
|
689
783
|
// 诊断:窗口前 120 字符(仅 SSH_TOOL_DEBUG=1 时落盘)
|
|
690
784
|
debug(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口前120: ${escapeCtl(raw.slice(0, 120))}`);
|
|
@@ -693,16 +787,16 @@ export class BaseSession {
|
|
|
693
787
|
// **不得退回整窗**——整窗会把重放的别会话命令行/标记当输出落盘(旧 bug:outLen=14019 全是杂物)。
|
|
694
788
|
// 改为按窗口内已有标记边界裁剪:取"本命令自己的标记之前、所有非本 seq 标记之后"的边界,
|
|
695
789
|
// 先把重放段整段切掉,再剥行首残余控制码噪音;定位仍失败也只剩边界之后的少量内容。
|
|
696
|
-
const ownEnd =
|
|
697
|
-
const
|
|
698
|
-
let fallback =
|
|
790
|
+
const ownEnd = boundary;
|
|
791
|
+
const fbBoundary = this._fallbackBoundary(raw, ownEnd);
|
|
792
|
+
let fallback = fbBoundary > 0 ? raw.slice(fbBoundary) : raw;
|
|
699
793
|
// 边界裁剪后"再试一次定位":此时窗口已剔除重放段,回显残片可能恢复成可匹配的连续文本
|
|
700
794
|
const retry = extractOutputStart(fallback, echoText, fallback.length, false);
|
|
701
795
|
if (retry > 0)
|
|
702
796
|
fallback = fallback.slice(retry);
|
|
703
797
|
else
|
|
704
798
|
fallback = this._stripLeadingControlNoise(fallback);
|
|
705
|
-
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 定位失败改用标记边界: boundary=${
|
|
799
|
+
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 定位失败改用标记边界: boundary=${fbBoundary} retry=${retry} 裁剪后长=${fallback.length} 原窗长=${raw.length}`);
|
|
706
800
|
// 中断命令再截到首个 ^C 回显,避免输出残留 ^C 与探针后 shell 重印的提示符(与旧版中断收尾语义一致)
|
|
707
801
|
if (this._interruptSent) {
|
|
708
802
|
const intr = detectInterrupt(fallback, 0);
|
|
@@ -712,10 +806,15 @@ export class BaseSession {
|
|
|
712
806
|
return this._stripProbeEchoes(stripMarkers(fallback));
|
|
713
807
|
}
|
|
714
808
|
let out;
|
|
715
|
-
if (
|
|
716
|
-
//
|
|
809
|
+
if (syntaxMode) {
|
|
810
|
+
// 语法错误:命令整行作废、本条标记未执行 → 输出 = 回显结束 → 缓冲区末尾(含整段错误块)
|
|
811
|
+
//(不补发探针;尾部最多多 1 行提示符噪音,按设计接受)
|
|
812
|
+
out = raw.slice(end, boundary);
|
|
813
|
+
}
|
|
814
|
+
else if (marker.done || probe?.done) {
|
|
815
|
+
// 中断命令:完成由补发探针标记(SSH_PROBE)触发,输出窗口会多出 ^C 回显、重印提示符与探针回显;
|
|
717
816
|
// 输出在首个 ^C 回显处截断,保留中断前程序输出(探针回显另由 _stripProbeEchoes 剔除)
|
|
718
|
-
let endPos =
|
|
817
|
+
let endPos = boundary;
|
|
719
818
|
if (this._interruptSent) {
|
|
720
819
|
const intr = detectInterrupt(raw, end);
|
|
721
820
|
if (intr.interrupted)
|
|
@@ -723,13 +822,17 @@ export class BaseSession {
|
|
|
723
822
|
}
|
|
724
823
|
out = raw.slice(end, endPos);
|
|
725
824
|
}
|
|
726
|
-
else if (syntaxMode) {
|
|
727
|
-
// 语法错误:命令整行作废、无 marker → 输出 = 回显结束 → 窗口末尾(含整段错误块),再剥尾部重印提示符
|
|
728
|
-
out = stripTrailingPromptLine(raw.slice(end), raw, this._promptRef);
|
|
729
|
-
}
|
|
730
825
|
else {
|
|
731
|
-
|
|
732
|
-
|
|
826
|
+
// 无完成标记:仅当插件确实发过中断(_interruptSent)才把首个 ^C 当中断回显截断;
|
|
827
|
+
// 否则真实输出里的字面量 ^C(cat/echo 含 ^C 的文本、工具日志、记录过 Ctrl+C 的 transcript)
|
|
828
|
+
// 会被误当中断回显,导致输出在那里被切甚至全空
|
|
829
|
+
if (this._interruptSent) {
|
|
830
|
+
const intr = detectInterrupt(raw, end);
|
|
831
|
+
out = intr.interrupted ? raw.slice(end, intr.pos) : raw.slice(end);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
out = raw.slice(end);
|
|
835
|
+
}
|
|
733
836
|
}
|
|
734
837
|
return this._stripProbeEchoes(stripMarkers(out.replace(/^[\r\n]+/, "")));
|
|
735
838
|
}
|
|
@@ -789,8 +892,6 @@ export class BaseSession {
|
|
|
789
892
|
// captureStart=裁剪后残留长度(exec 的实际捕获起点,与缓冲内容相关,仅供参考)
|
|
790
893
|
const winStartPos = marker.done ? marker.pos : 0;
|
|
791
894
|
debug(`[capture] ${this._logTag} seq=${this._cmdSeq + 1} 起点=${winStartPos} captureStart=${this._buffer.length}(参考) entryCursor=${entryCursor} bufLen=${entryBufLen} lastMarkerPos=${marker.done ? marker.pos : -1}`);
|
|
792
|
-
// 记录命令发起前缓冲末行(旧提示符行):语法错误收尾时 shell 会重印同形提示符,供输出尾部剥离
|
|
793
|
-
this._promptRef = this._buffer.split("\n").pop() ?? "";
|
|
794
895
|
this._runningStartPos = 0;
|
|
795
896
|
this._runningCommand = command;
|
|
796
897
|
this._runningStartTs = Date.now();
|
|
@@ -848,7 +949,7 @@ export class BaseSession {
|
|
|
848
949
|
* @returns 无
|
|
849
950
|
*/
|
|
850
951
|
_sendBaselineMarker() {
|
|
851
|
-
const baseline = this._markerCmd(0) +
|
|
952
|
+
const baseline = this._markerCmd(0) + this._ptyLineSep();
|
|
852
953
|
debug(`[baseline] ${this._logTag} 初始化完成,发送基线标记 nonce=${this._nonce} seq=0`);
|
|
853
954
|
this._write(baseline);
|
|
854
955
|
}
|
|
@@ -860,8 +961,16 @@ export class BaseSession {
|
|
|
860
961
|
this._write(candidate.probeCommand + "\r");
|
|
861
962
|
const output = await this._waitProbeOutput(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()));
|
|
862
963
|
this._buffer = "";
|
|
863
|
-
if (output !== null && candidate.parseProbe(output))
|
|
964
|
+
if (output !== null && candidate.parseProbe(output)) {
|
|
965
|
+
// 终端平台按真实 shell 判断:cmd 必为 Windows;pwsh 看 $PSHOME 是 Windows 路径还是 Unix 路径;
|
|
966
|
+
// bash/zsh 走 Unix PTY。这样 SSH 到 Linux 上的 pwsh 也会用 \n(不依赖 ICRNL)。
|
|
967
|
+
this._windowsPTY = candidate.name === "cmd"
|
|
968
|
+
? true
|
|
969
|
+
: candidate.name === "pwsh"
|
|
970
|
+
? /__SHELL_ID__pwsh_[A-Za-z]:[\\/]/.test(output)
|
|
971
|
+
: false;
|
|
864
972
|
return candidate;
|
|
973
|
+
}
|
|
865
974
|
}
|
|
866
975
|
}
|
|
867
976
|
return null;
|
|
@@ -932,13 +1041,21 @@ export class BaseSession {
|
|
|
932
1041
|
return;
|
|
933
1042
|
}
|
|
934
1043
|
try {
|
|
935
|
-
//
|
|
1044
|
+
// 真实完成(权威且唯一):只认本会话 nonce + 本 seq 的 SSH_DONE 标记。
|
|
1045
|
+
// 探针已改用独立 SSH_PROBE tag,二者不再混淆——语法误判时真实退出码不会被覆盖。
|
|
936
1046
|
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
|
|
937
1047
|
if (marker.done) {
|
|
938
1048
|
this._settleCompletion({ kind: "done", markerPos: marker.pos, exitCode: marker.exitCode });
|
|
939
1049
|
return;
|
|
940
1050
|
}
|
|
941
|
-
//
|
|
1051
|
+
// 中断探针(SSH_PROBE):Ctrl+C 后 shell 回到提示符执行补发的标记命令。
|
|
1052
|
+
// 语法错误路径不再补发探针(见 _armSyntaxQuiet):避免把命令写进仍在运行的程序 stdin。
|
|
1053
|
+
const probe = detectLastProbeMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
|
|
1054
|
+
if (probe.done) {
|
|
1055
|
+
this._settleCompletion({ kind: "done", markerPos: probe.pos, exitCode: probe.exitCode });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
// 语法/解析错误:整行命令作废(含同行拼装的完成标记也不会执行)→ 输出安静后补发探针按完成收尾
|
|
942
1059
|
if (detectSyntaxError(this._adapter, this._buffer, this._watchStartPos)) {
|
|
943
1060
|
// 兜底 tick 不重置已挂的静默计时(否则每周期顺延、永不收尾);数据到达时才推进
|
|
944
1061
|
if (fromData || this._syntaxTimer === null)
|
|
@@ -982,9 +1099,11 @@ export class BaseSession {
|
|
|
982
1099
|
this._completeRunning(this._watchStartPos, o.markerPos ?? this._buffer.length, o.exitCode ?? null, this._watchCommand);
|
|
983
1100
|
}
|
|
984
1101
|
/**
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1102
|
+
* 推进语法错误安静窗口(一次性定时器,非轮询):
|
|
1103
|
+
* 识别到语法错误后等输出停止增长 SYNTAX_QUIET_MS,确保 pwsh 异步/批量渲染的多行错误块完整收进输出窗口;
|
|
1104
|
+
* 到点**直接按语法错误收尾,不再补发探针**——探针会被写进仍在运行的程序 stdin(如 `cat >file` / REPL),
|
|
1105
|
+
* 且语法误判时会提前收尾、丢弃迟到的真实完成标记;尾部最多多 1 行提示符噪音,按设计接受。
|
|
1106
|
+
* 自首次识别起硬上限 SYNTAX_MAX_WAIT_MS;数据到达(输出变化)时重置安静窗口;兜底 tick 不重置(见 _checkCompletion)。
|
|
988
1107
|
* @returns 无
|
|
989
1108
|
*/
|
|
990
1109
|
_armSyntaxQuiet() {
|
|
@@ -1001,7 +1120,7 @@ export class BaseSession {
|
|
|
1001
1120
|
}
|
|
1002
1121
|
if (now - this._syntaxSeenAt >= SYNTAX_MAX_WAIT_MS) {
|
|
1003
1122
|
this._clearSyntaxTimer();
|
|
1004
|
-
this.
|
|
1123
|
+
this._settleSyntaxError();
|
|
1005
1124
|
return;
|
|
1006
1125
|
}
|
|
1007
1126
|
const remain = Math.max(0, SYNTAX_QUIET_MS - (now - this._syntaxQuietAt));
|
|
@@ -1010,14 +1129,18 @@ export class BaseSession {
|
|
|
1010
1129
|
this._syntaxTimer = null;
|
|
1011
1130
|
if (this._runningStartPos === null)
|
|
1012
1131
|
return;
|
|
1013
|
-
|
|
1014
|
-
this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
|
|
1015
|
-
}
|
|
1016
|
-
catch (e) {
|
|
1017
|
-
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 语法静默收尾异常: err=${e instanceof Error ? e.message : String(e)}`);
|
|
1018
|
-
}
|
|
1132
|
+
this._settleSyntaxError();
|
|
1019
1133
|
}, remain);
|
|
1020
1134
|
}
|
|
1135
|
+
/** 按语法错误收尾(含异常兜底:收尾本身再抛也不能变成未捕获异常) */
|
|
1136
|
+
_settleSyntaxError() {
|
|
1137
|
+
try {
|
|
1138
|
+
this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
|
|
1139
|
+
}
|
|
1140
|
+
catch (e) {
|
|
1141
|
+
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 语法错误收尾异常: err=${e instanceof Error ? e.message : String(e)}`);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1021
1144
|
/**
|
|
1022
1145
|
* 断连收尾(由后台兜底 tick 判定 _connected=false 触发):命令视为结束(可能从未完成),
|
|
1023
1146
|
* 补发 done 防 server busy 卡死;不写 history(输出窗口从未定案,无可信尾部可摘)。
|
|
@@ -1037,7 +1160,7 @@ export class BaseSession {
|
|
|
1037
1160
|
}
|
|
1038
1161
|
}
|
|
1039
1162
|
/**
|
|
1040
|
-
*
|
|
1163
|
+
* 开始一次命令的后台追踪:记录兜底判定所需的捕获窗口起点/命令,并复位语法错误状态。
|
|
1041
1164
|
* 不做任何轮询——完成判定由数据到达事件驱动(见 `_onDataArrived`)。
|
|
1042
1165
|
* @param startPos 捕获窗口起点(相对当前 buffer;submit 为 0,exec 转后台为 captureStart)
|
|
1043
1166
|
* @param command 命令(history 记录用)
|
|
@@ -1115,7 +1238,13 @@ export class BaseSession {
|
|
|
1115
1238
|
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 输出提取异常, 使用整窗输出: cmdLen=${command.length} err=${e instanceof Error ? e.message : String(e)}`);
|
|
1116
1239
|
output = raw;
|
|
1117
1240
|
}
|
|
1118
|
-
|
|
1241
|
+
try {
|
|
1242
|
+
this._history.append(command, output, this._runningStartTs, undefined, exitCode ?? undefined);
|
|
1243
|
+
}
|
|
1244
|
+
catch (e) {
|
|
1245
|
+
// history 落盘失败(目录被删/权限/磁盘)不得阻断 done/busy 收尾,否则会话永久 busy
|
|
1246
|
+
log.info(`[history] ${this._logTag} seq=${this._runningSeq} 追加失败: err=${e instanceof Error ? e.message : String(e)}`);
|
|
1247
|
+
}
|
|
1119
1248
|
this._emitDone(exitCode, Date.now(), output);
|
|
1120
1249
|
this._cursor = Math.max(startPos, endPos);
|
|
1121
1250
|
this._setBusy(false, "cmdDone", true); // 命令完成即清 busy("保持 busy 到排队序列结束"的机制已移除);释放行紧随 [run] 结束,降 debug 免重复
|