@xiaoqiong0v0/opencode-ssh-tool 0.6.15 → 0.6.17

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.
Files changed (55) hide show
  1. package/README.md +15 -10
  2. package/dist/agent.d.ts +4 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +17 -2
  5. package/dist/agent.js.map +1 -1
  6. package/dist/base-session.d.ts +64 -32
  7. package/dist/base-session.d.ts.map +1 -1
  8. package/dist/base-session.js +235 -106
  9. package/dist/base-session.js.map +1 -1
  10. package/dist/constants.d.ts +7 -1
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +7 -1
  13. package/dist/constants.js.map +1 -1
  14. package/dist/i18n.d.ts +22 -2
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +22 -2
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +28 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/local-session.d.ts +3 -1
  22. package/dist/local-session.d.ts.map +1 -1
  23. package/dist/local-session.js +82 -4
  24. package/dist/local-session.js.map +1 -1
  25. package/dist/log.d.ts +8 -0
  26. package/dist/log.d.ts.map +1 -1
  27. package/dist/log.js +28 -1
  28. package/dist/log.js.map +1 -1
  29. package/dist/parse-check.d.ts +20 -0
  30. package/dist/parse-check.d.ts.map +1 -0
  31. package/dist/parse-check.js +505 -0
  32. package/dist/parse-check.js.map +1 -0
  33. package/dist/precheck.d.ts +32 -0
  34. package/dist/precheck.d.ts.map +1 -0
  35. package/dist/precheck.js +105 -0
  36. package/dist/precheck.js.map +1 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +41 -5
  39. package/dist/server.js.map +1 -1
  40. package/dist/session-store.d.ts +33 -0
  41. package/dist/session-store.d.ts.map +1 -1
  42. package/dist/session-store.js +55 -0
  43. package/dist/session-store.js.map +1 -1
  44. package/dist/session.js +1 -1
  45. package/dist/session.js.map +1 -1
  46. package/dist/shell-adapter.d.ts +14 -0
  47. package/dist/shell-adapter.d.ts.map +1 -1
  48. package/dist/shell-adapter.js +58 -10
  49. package/dist/shell-adapter.js.map +1 -1
  50. package/dist/utils.d.ts +8 -34
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +22 -64
  53. package/dist/utils.js.map +1 -1
  54. package/dist/web/app.js +1 -1
  55. package/package.json +1 -1
@@ -1,48 +1,98 @@
1
1
  // BaseSession:SSH(SshSession) 与本地(LocalSession) 终端的公共基类
2
- // 统一完成标记法:注入 PROMPT_COMMAND 脚本,命令完成时输出 <SSH_DONE:退出码>
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, stripTrailingPromptLine, removeProbeEchoLines, ECHO_REGEX_MAX_CMD_CHARS, echoRegexSourceLength } from "./utils.js";
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
- * 避免 `echo "a#b"` / `echo 'a#b'` 引号内的 # 被误剥,也避免 marker 被真注释吞掉。
19
- * @param s 命令文本
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 escaped = false;
35
+ let inBacktick = false;
26
36
  for (let i = 0; i < s.length; i++) {
27
37
  const c = s[i];
28
- if (escaped) {
29
- escaped = false;
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
- escaped = true;
75
+ if (c === "'") {
76
+ inSingle = true;
34
77
  continue;
35
78
  }
36
- if (c === "'" && !inDouble) {
37
- inSingle = !inSingle;
79
+ if (c === '"') {
80
+ inDouble = true;
38
81
  continue;
39
82
  }
40
- if (c === '"' && !inSingle) {
41
- inDouble = !inDouble;
83
+ if (!pwsh && c === "`") {
84
+ inBacktick = true;
42
85
  continue;
43
86
  }
44
- if (c === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(s[i - 1]))) {
45
- return s.slice(0, i).trimEnd();
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
- // 仅处理最后一行:多行命令(如 heredoc 正文)中间的 `#` 是内容,若在此截断会把命令切断
59
- const nl = command.lastIndexOf("\n");
60
- const head = nl >= 0 ? command.slice(0, nl + 1) : "";
61
- const last = nl >= 0 ? command.slice(nl + 1) : command;
62
- let s = stripTrailingComment(last);
63
- s = s.replace(/(?:&&|\|\||[;&|])[\s]*$/, "");
64
- return (head + s).trimEnd();
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._stopInterruptCycle("会话关闭");
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
- * 行分隔符按**探测到的 shell 类型/平台**决定,而非按传输类型(本地/SSH):
502
- * - Windows shell(pwsh/cmd,控制台为 ConPTY)→ `\r`;本地 ConPTY 会话(无 adapter 时由 `_lineSep` 覆盖为 `\r`)→ `\r`;
503
- * - SSH + POSIX(zsh/bash,走 Unix PTY)→ `\n`(`_lineSep` 默认值)。
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
- * 实测 SSH 连到 Windows 的 pwsh:用 `\n` 分隔时命令逆序卡死,改用 `\r` 后行序正确、组内作用域保留、输出正常。
506
- * Unix PTY 下 `\n` 可正常提交行,保持不变。
581
+ * Unix PTY 下 `\n` 可正常提交行。
507
582
  * @param command 原始命令(history 存干净版本)
508
- * @returns 实际写入 PTY 的文本(尾部带 \r,行分隔已按 shell 类型/平台规范化)
583
+ * @returns 实际写入 PTY 的文本(尾部带提交分隔符,行分隔已按平台规范化)
509
584
  */
510
585
  _composeCommand(command) {
511
- // 行分隔符按 shell 类型/平台判定(见 _ptyLineSep)
512
- return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) + "\r";
586
+ // 行分隔符按终端平台判定(见 _ptyLineSep)
587
+ return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) + this._ptyLineSep();
513
588
  }
514
589
  /**
515
- * 当前 shell 的 PTY **提交分隔符**(让 shell 执行该行):Windows shell(pwsh/cmd,ConPTY)→ `\r`
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._adapter?.windowsShell ? "\r" : this._lineSep;
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._markerCmd(seq) + "\r";
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
- * 补发一次中断探针:用户显式 Ctrl+C 后,前台命令可能已死、shell 已回到提示符,
539
- * 但命令列表被中断丢弃 → 原命令的完成标记永不执行 → busy 卡死。
540
- * 补写一条"裸标记命令"作为探针:shell 若已回到提示符会读它并打印当前 seq 标记,
541
- * 使命令正常收尾;若前台程序未死,字节留在 tty 输入缓冲,待 shell 拿回控制权时再读
542
- * (两种情况均符合预期,保持 busy、绝不误判完成)。
543
- * 由中断周期调用:写完 \x03 时立即调用一次,之后定时器每间隔调用一次(见 _startInterruptCycle)。
544
- * 幂等:同一条被中断命令多次补发(立即 1 条 + 每间隔 1 条),先到的当前 seq 标记胜出。
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
- _sendInterruptProbe() {
548
- // 仅在确实有正在追踪的命令时补发:空闲期 Ctrl+C 无待收尾命令,补发只会污染缓冲
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._probeSeqs.add(seq);
639
+ this._rememberProbeSeq(seq);
553
640
  const probe = this._composeProbe(seq);
554
- debug(`中断探针 ${this._logTag} seq=${seq} ${JSON.stringify(probe)} (busy=${this._remoteBusy})`);
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._sendInterruptProbe();
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._sendInterruptProbe();
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
- * 探针在用户中断时补发,其回显可能迟到落到后续命令的输出窗口里;这里对每个已记录探针 seq
607
- * (以及当前命令自身的标记命令回显)做 **ANSI 容忍定位**:即便 pwsh 语法高亮在 token 间插入
692
+ * 从可见输出中剔除探针留下的痕迹(探针命令的回显)。
693
+ * 探针命令已改为独立 PROBE tag,但其回显仍是"一条标记命令";同时保留对当前命令 DONE 标记回显的剔除。
694
+ * 对每个已记录探针 seq(以及当前命令自身)做 **ANSI 容忍定位**:即便 pwsh 语法高亮在 token 间插入
608
695
  * SGR、行首带提示符与 `\x1b[?25l`,也能命中并整段剔除(见 removeProbeEchoLines)。
609
- * 三处调用点(本函数、_extractOutput 的 out.replace、getRunningStream 的 data.replace)
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.push(this._markerCmd(seq));
618
- // 当前命令的标记命令回显:原 out.replace(_markerCmd(_runningSeq)) / data.replace(...) 两种口径并入此处
619
- needles.push(this._markerCmd(this._runningSeq));
620
- return removeProbeEchoLines(text, needles, this._promptRef);
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
- * - 剥掉尾部 shell 重印的提示符行(与旧提示符行同形,属画面噪音)。
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 = marker.done
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 = marker.done ? marker.pos : raw.length;
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 = marker.done ? marker.pos : raw.length;
697
- const boundary = this._fallbackBoundary(raw, ownEnd);
698
- let fallback = boundary > 0 ? raw.slice(boundary) : raw;
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=${boundary} retry=${retry} 裁剪后长=${fallback.length} 原窗长=${raw.length}`);
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 (marker.done) {
716
- // 中断命令:完成标记由探针补发,输出窗口会多出 ^C 回显、重印提示符与探针回显;
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 = marker.pos;
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
- const intr = detectInterrupt(raw, end);
732
- out = intr.interrupted ? raw.slice(end, intr.pos) : raw.slice(end);
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) + "\r";
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
- // 完成判定(权威且唯一):命令后追加的 printf 输出 <SSH_DONE:seq:code>;marker 自带唯一 seq,从命令起点搜索
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
- * 推进语法错误安静窗口(一次性定时器,非轮询):识别到语法错误后等输出停止增长 SYNTAX_QUIET_MS 再收尾,
986
- * 确保 pwsh 异步/批量渲染的多行错误块被完整收进输出窗口;自首次识别起上限 SYNTAX_MAX_WAIT_MS。
987
- * 数据到达(输出变化)时重置安静窗口;兜底 tick 不重置(见 _checkCompletion)。
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._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
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
- try {
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
- this._history.append(command, output, this._runningStartTs, undefined, exitCode ?? undefined);
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 免重复