@xiaoqiong0v0/opencode-ssh-tool 0.6.14 → 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.
Files changed (58) hide show
  1. package/dist/agent.d.ts +4 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +17 -2
  4. package/dist/agent.js.map +1 -1
  5. package/dist/base-session.d.ts +68 -32
  6. package/dist/base-session.d.ts.map +1 -1
  7. package/dist/base-session.js +253 -130
  8. package/dist/base-session.js.map +1 -1
  9. package/dist/constants.d.ts +14 -1
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +14 -1
  12. package/dist/constants.js.map +1 -1
  13. package/dist/i18n.d.ts +36 -0
  14. package/dist/i18n.d.ts.map +1 -1
  15. package/dist/i18n.js +33 -0
  16. package/dist/i18n.js.map +1 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +28 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/local-session.d.ts +3 -1
  21. package/dist/local-session.d.ts.map +1 -1
  22. package/dist/local-session.js +82 -4
  23. package/dist/local-session.js.map +1 -1
  24. package/dist/log.d.ts +8 -0
  25. package/dist/log.d.ts.map +1 -1
  26. package/dist/log.js +28 -1
  27. package/dist/log.js.map +1 -1
  28. package/dist/parse-check.d.ts +20 -0
  29. package/dist/parse-check.d.ts.map +1 -0
  30. package/dist/parse-check.js +505 -0
  31. package/dist/parse-check.js.map +1 -0
  32. package/dist/precheck.d.ts +32 -0
  33. package/dist/precheck.d.ts.map +1 -0
  34. package/dist/precheck.js +105 -0
  35. package/dist/precheck.js.map +1 -0
  36. package/dist/server-entry.js +1 -1
  37. package/dist/server-entry.js.map +1 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +123 -35
  40. package/dist/server.js.map +1 -1
  41. package/dist/session-store.d.ts +56 -2
  42. package/dist/session-store.d.ts.map +1 -1
  43. package/dist/session-store.js +100 -3
  44. package/dist/session-store.js.map +1 -1
  45. package/dist/session.js +1 -1
  46. package/dist/session.js.map +1 -1
  47. package/dist/shell-adapter.d.ts +14 -0
  48. package/dist/shell-adapter.d.ts.map +1 -1
  49. package/dist/shell-adapter.js +58 -10
  50. package/dist/shell-adapter.js.map +1 -1
  51. package/dist/utils.d.ts +33 -20
  52. package/dist/utils.d.ts.map +1 -1
  53. package/dist/utils.js +97 -43
  54. package/dist/utils.js.map +1 -1
  55. package/dist/web/app.css +4 -0
  56. package/dist/web/app.js +10 -10
  57. package/dist/web/index.html +1 -0
  58. 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";
6
- import log, { debug, describeSend, isDebugEnabled, tailLines, tailSummary } from "./log.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
+ 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, 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;
@@ -262,7 +314,7 @@ export class BaseSession {
262
314
  const completion = this._awaitCompletion();
263
315
  this._write(this._composeCommand(command));
264
316
  const outcome = await completion;
265
- log.info(`exec ${this._logTag} seq=${this._runningSeq} cmdLen=${command.length} -> ${outcome.kind}${outcome.kind === "running" ? "" : " markerPos=" + outcome.markerPos + " exitCode=" + outcome.exitCode + " duration=" + (Date.now() - startTs) + "ms"} (busy=${this._remoteBusy})`);
317
+ debug(`exec ${this._logTag} seq=${this._runningSeq} cmdLen=${command.length} -> ${outcome.kind}${outcome.kind === "running" ? "" : " markerPos=" + outcome.markerPos + " exitCode=" + outcome.exitCode + " duration=" + (Date.now() - startTs) + "ms"} (busy=${this._remoteBusy})`);
266
318
  debug(`exec ${this._logTag} seq=${this._runningSeq} cmd=${tailLines(command, 3)}`);
267
319
  switch (outcome.kind) {
268
320
  case "done": {
@@ -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 "";
@@ -389,12 +449,18 @@ export class BaseSession {
389
449
  * 供事后对齐状态时间线(busy 每次翻转都能对应到具体原因,不再出现"忙了却查不出为何")。
390
450
  * @param next 目标 busy 值
391
451
  * @param reason 翻转原因(cmdStart / cmdRunning / cmdDone / 会话关闭 / 断连补 done 等)
452
+ * @param quiet true 时该行降为 debug:用于"释放"与紧随其后的收尾行(如 [run] 结束)重复的场景,
453
+ * 避免同一事件打两条 info(busy 时间线仍可由该收尾行还原)
392
454
  */
393
- _setBusy(next, reason) {
455
+ _setBusy(next, reason, quiet = false) {
394
456
  if (this._remoteBusy === next)
395
457
  return;
396
458
  this._remoteBusy = next;
397
- log.info(`[busy] ${this._logTag} seq=${this._runningSeq} busy=${next} reason=${reason}`);
459
+ const line = `[busy] ${this._logTag} seq=${this._runningSeq} busy=${next} reason=${reason}`;
460
+ if (quiet)
461
+ debug(line);
462
+ else
463
+ log.info(line);
398
464
  // 通知插件同步状态文件(busy 已更新,getStatus 读到新值);监听器异常不打断执行
399
465
  try {
400
466
  this._busyListener?.(next, reason);
@@ -459,10 +525,7 @@ export class BaseSession {
459
525
  }
460
526
  const raw = this._buffer.slice(this._streamPos, windowEnd);
461
527
  this._streamPos = windowEnd;
462
- let data = stripMarkers(raw);
463
- if (this._adapter)
464
- data = data.replace(this._markerCmd(this._runningSeq), "");
465
- data = this._stripProbeEchoes(data);
528
+ const data = this._stripProbeEchoes(stripMarkers(raw));
466
529
  return { data, done: marker.done };
467
530
  }
468
531
  /** 关闭终端,清理会话态(幂等) */
@@ -472,7 +535,10 @@ export class BaseSession {
472
535
  this._closed = true;
473
536
  if (this._watchTimer)
474
537
  clearInterval(this._watchTimer);
475
- this._stopInterruptCycle("会话关闭");
538
+ this._watchTimer = null;
539
+ // 先清运行上下文(含语法/中断定时器),再释放 history:
540
+ // 否则 close 后定时器仍可能触发写已关闭终端,并在 history 已 dispose 时 append 抛未捕获 ENOENT
541
+ this._clearRunningContext();
476
542
  this._closeTransport();
477
543
  this._connected = false;
478
544
  this._setBusy(false, "会话关闭");
@@ -493,28 +559,39 @@ export class BaseSession {
493
559
  _markerCmd(seq) {
494
560
  return this._adapter ? this._adapter.markerCmd(this._nonce, seq) : `printf '\\n${DONE_TAG}${this._nonce}:${seq}:%s>' $?`;
495
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
+ }
496
574
  /**
497
575
  * 组合实际写入 PTY 的命令:在规范形式(`_composeEchoText`)基础上把换行改写为行分隔符并补尾部回车。
498
- * 行分隔符按**探测到的 shell 类型/平台**决定,而非按传输类型(本地/SSH):
499
- * - Windows shell(pwsh/cmd,控制台为 ConPTY)→ `\r`;本地 ConPTY 会话(无 adapter 时由 `_lineSep` 覆盖为 `\r`)→ `\r`;
500
- * - 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 `/...`)。
501
580
  * 原因:ConPTY 下裸 `\n` 只下移不复位、不提交行 → 多行命令逆序执行并卡在 `>>` 续行,须用 `\r`;
502
- * 实测 SSH 连到 Windows 的 pwsh:用 `\n` 分隔时命令逆序卡死,改用 `\r` 后行序正确、组内作用域保留、输出正常。
503
- * Unix PTY 下 `\n` 可正常提交行,保持不变。
581
+ * Unix PTY 下 `\n` 可正常提交行。
504
582
  * @param command 原始命令(history 存干净版本)
505
- * @returns 实际写入 PTY 的文本(尾部带 \r,行分隔已按 shell 类型/平台规范化)
583
+ * @returns 实际写入 PTY 的文本(尾部带提交分隔符,行分隔已按平台规范化)
506
584
  */
507
585
  _composeCommand(command) {
508
- // 行分隔符按 shell 类型/平台判定(见 _ptyLineSep)
509
- return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) + "\r";
586
+ // 行分隔符按终端平台判定(见 _ptyLineSep)
587
+ return this._composeEchoText(command).replace(/\n/g, this._ptyLineSep()) + this._ptyLineSep();
510
588
  }
511
589
  /**
512
- * 当前 shell 的 PTY **提交分隔符**(让 shell 执行该行):Windows shell(pwsh/cmd,ConPTY)→ `\r`
513
- * (裸 \n 不提交行);其余沿用会话 `_lineSep`(Unix PTY 为 \n,本地会话为 \r)。
590
+ * 当前终端的 PTY **提交分隔符**:Windows ConPTY → `\r`;Unix PTY → `_lineSep`(通常 `\n`)。
514
591
  * @returns 提交分隔符字符串
515
592
  */
516
593
  _ptyLineSep() {
517
- return this._adapter?.windowsShell ? "\r" : this._lineSep;
594
+ return this._windowsPTY ? "\r" : this._lineSep;
518
595
  }
519
596
  /**
520
597
  * 组合中断探针写入文本:**不加任何前缀换行**,直接写一条独立的当前 seq 完成标记命令,
@@ -529,26 +606,39 @@ export class BaseSession {
529
606
  * @returns 写入 PTY 的探针文本
530
607
  */
531
608
  _composeProbe(seq) {
532
- 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
+ }
533
620
  }
534
621
  /**
535
- * 补发一次中断探针:用户显式 Ctrl+C 后,前台命令可能已死、shell 已回到提示符,
536
- * 但命令列表被中断丢弃 → 原命令的完成标记永不执行 → busy 卡死。
537
- * 补写一条"裸标记命令"作为探针:shell 若已回到提示符会读它并打印当前 seq 标记,
538
- * 使命令正常收尾;若前台程序未死,字节留在 tty 输入缓冲,待 shell 拿回控制权时再读
539
- * (两种情况均符合预期,保持 busy、绝不误判完成)。
540
- * 由中断周期调用:写完 \x03 时立即调用一次,之后定时器每间隔调用一次(见 _startInterruptCycle)。
541
- * 幂等:同一条被中断命令多次补发(立即 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 补发原因(仅用于日志:中断探针)
542
631
  * @returns 无
543
632
  */
544
- _sendInterruptProbe() {
545
- // 仅在确实有正在追踪的命令时补发:空闲期 Ctrl+C 无待收尾命令,补发只会污染缓冲
633
+ _sendProbe(reason) {
634
+ if (!this._connected)
635
+ return;
546
636
  if (this._runningStartPos === null || this._runningCommand === "")
547
637
  return;
548
638
  const seq = this._runningSeq;
549
- this._probeSeqs.add(seq);
639
+ this._rememberProbeSeq(seq);
550
640
  const probe = this._composeProbe(seq);
551
- debug(`中断探针 ${this._logTag} seq=${seq} ${JSON.stringify(probe)} (busy=${this._remoteBusy})`);
641
+ debug(`${reason} ${this._logTag} seq=${seq} ${JSON.stringify(probe)} (busy=${this._remoteBusy})`);
552
642
  this._write(probe);
553
643
  }
554
644
  /**
@@ -570,7 +660,7 @@ export class BaseSession {
570
660
  const intervalSec = this._interruptProbeIntervalMs / 1000;
571
661
  log.info(`中断周期开始 ${this._logTag} seq=${seq},已立即补发首条探针,之后每 ${intervalSec}s 一条`);
572
662
  // 写完 \x03 后立即补发首条探针(t0),后续 setInterval 自此刻起按间隔补发(t0+N、t0+2N…)
573
- this._sendInterruptProbe();
663
+ this._sendProbe("中断探针");
574
664
  this._interruptTimer = setInterval(() => { this._onInterruptTick(); }, this._interruptProbeIntervalMs);
575
665
  }
576
666
  /**
@@ -583,7 +673,7 @@ export class BaseSession {
583
673
  this._stopInterruptCycle("命令已结束或会话关闭");
584
674
  return;
585
675
  }
586
- this._sendInterruptProbe();
676
+ this._sendProbe("中断探针");
587
677
  }
588
678
  /**
589
679
  * 停止中断周期并清周期状态:清定时器,日志记录结束原因(幂等,未在周期内时不做任何事)。
@@ -599,23 +689,24 @@ export class BaseSession {
599
689
  log.info(`中断周期结束 ${this._logTag} seq=${this._runningSeq},原因=${reason}`);
600
690
  }
601
691
  /**
602
- * 从可见输出中剔除中断探针留下的痕迹(探针命令的回显)。
603
- * 探针在用户中断时补发,其回显可能迟到落到后续命令的输出窗口里;按已记录探针 seq
604
- * 生成精确回显文本并删除全部出现,从而无论何时到达都剔除。完成标记本身由 stripMarkers
605
- * 按标记模式统一去除(不限 seq)。Raw 视图不经此函数,保留原始字节。
692
+ * 从可见输出中剔除探针留下的痕迹(探针命令的回显)。
693
+ * 探针命令已改为独立 PROBE tag,但其回显仍是"一条标记命令";同时保留对当前命令 DONE 标记回显的剔除。
694
+ * 对每个已记录探针 seq(以及当前命令自身)做 **ANSI 容忍定位**:即便 pwsh 语法高亮在 token 间插入
695
+ * SGR、行首带提示符与 `\x1b[?25l`,也能命中并整段剔除(见 removeProbeEchoLines)。
696
+ * 三处调用点(本函数、getRunningStream、readBuffer)统一收敛至此;Raw 视图保留原始字节。
606
697
  * @param text 可见输出文本
607
698
  * @returns 剔除探针回显后的文本
608
699
  */
609
700
  _stripProbeEchoes(text) {
610
- if (this._probeSeqs.size === 0)
611
- return text;
612
- let out = text;
701
+ const needles = new Set();
613
702
  for (const seq of this._probeSeqs) {
614
- const echo = this._markerCmd(seq);
615
- if (out.includes(echo))
616
- out = out.split(echo).join("");
703
+ needles.add(this._markerCmd(seq));
704
+ needles.add(this._probeCmd(seq));
617
705
  }
618
- return out;
706
+ // 当前命令的标记命令回显:与上面可能重复,Set 去重避免同一 needle 反复扫描
707
+ needles.add(this._markerCmd(this._runningSeq));
708
+ needles.add(this._probeCmd(this._runningSeq));
709
+ return removeProbeEchoLines(text, [...needles]);
619
710
  }
620
711
  /**
621
712
  * 组合命令文本(规范/匹配用形式,始终以 `\n` 分隔行、无尾部回车):命令 + 完成标记。
@@ -636,8 +727,11 @@ export class BaseSession {
636
727
  */
637
728
  _composeEchoText(command) {
638
729
  const marker = this._markerCmd(this._runningSeq);
639
- const body = stripCommandTail(command);
730
+ const body = stripCommandTail(command, this._adapter?.name ?? "");
640
731
  const sep = this._adapter?.stmtSep ?? ";";
732
+ // cmd:marker 必须独占下一行——%errorlevel% 是整行解析期展开,同行拼接会取到旧值
733
+ if (this._adapter?.name === "cmd")
734
+ return `${body}\n${marker}`;
641
735
  // 多行命令:尝试包组(cmd 返回 null → 回退原样拼接),使整段作为一条命令执行
642
736
  if (body.includes("\n")) {
643
737
  const grouped = this._adapter?.groupWrap(body);
@@ -654,7 +748,7 @@ export class BaseSession {
654
748
  * 语法错误路径(syntaxMode)另作两处特殊处理:
655
749
  * - 回显定位取**首次**匹配:pwsh 错误块的 ` 1 | <命令>` 行会重印含标记的整行命令,
656
750
  * 末次匹配会落到该拷贝上,导致 `ParserError:`/`Line |` 等错误块前半被裁掉;
657
- * - 剥掉尾部 shell 重印的提示符行(与旧提示符行同形,属画面噪音)。
751
+ * - 输出窗口右界取缓冲区末尾(不再补发语法探针),尾部最多多 1 行提示符噪音。
658
752
  * @param raw 本次执行窗口原始字节流
659
753
  * @param command 原始命令(历史用)
660
754
  * @param syntaxMode true 表示语法/解析错误收尾(无完成标记,整行命令作废)
@@ -663,33 +757,29 @@ export class BaseSession {
663
757
  _extractOutput(raw, command, syntaxMode = false) {
664
758
  // 完成标记先定位:marker 自带唯一 seq,从窗口起点搜索即可(echoEnd 可能被连续输入的后序回显带偏)
665
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);
666
764
  const echoText = this._composeEchoText(command);
667
765
  const echoLen = echoText.trim().length;
668
766
  // 超长回显:extractOutputStart 会降级为"首/尾锚点"匹配(否则模式源约 265×长度,JSC 下会抛 too large)
669
767
  if (echoLen > ECHO_REGEX_MAX_CMD_CHARS) {
670
- log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 回显定位降级为首/尾锚点: echoLen=${echoLen} regexSrcLen=${echoRegexSourceLength(echoText)} (>阈值 ${ECHO_REGEX_MAX_CMD_CHARS})`);
768
+ debug(`[extract] ${this._logTag} seq=${this._runningSeq} 回显定位降级为首/尾锚点: echoLen=${echoLen} regexSrcLen=${echoRegexSourceLength(echoText)} (>阈值 ${ECHO_REGEX_MAX_CMD_CHARS})`);
671
769
  }
672
770
  let end;
673
- const extractStart = Date.now();
674
771
  try {
675
- end = marker.done
676
- ? extractOutputStart(raw, echoText, marker.pos)
677
- : extractOutputStart(raw, echoText, raw.length, syntaxMode);
772
+ end = extractOutputStart(raw, echoText, boundary, syntaxMode);
678
773
  }
679
774
  catch (e) {
680
775
  // 回显定位异常绝不上抛(否则会中断完成收尾):记一行后退回整窗输出
681
776
  log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 回显定位失败, 退回整窗: echoLen=${echoLen} err=${e instanceof Error ? e.message : String(e)}`);
682
777
  end = -1;
683
778
  }
684
- // 诊断:定位结果与耗时(ret<=0 显眼标注"定位失败")
685
- log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 定位: inLen=${raw.length} patternLen=${echoRegexSourceLength(echoText)} ret=${end <= 0 ? `<${end}> 定位失败` : end} elapsedMs=${Date.now() - extractStart}`);
686
779
  // 诊断:输出窗口坐标 + 是否退回整窗(startPos=回显结束偏移,endPos=窗口末尾;窗口长=endPos-startPos)
687
780
  const winStart = end <= 0 ? 0 : end;
688
- const winEnd = marker.done ? marker.pos : raw.length;
689
- log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口: startPos=${winStart} endPos=${winEnd} 窗口长=${winEnd - winStart} markerSeq=${this._runningSeq} 是否退回整窗=${end <= 0 ? `是(定位失败, syntaxMode=${syntaxMode})` : "否"}`);
690
- // 诊断:只扫本命令窗口 [winStart, winEnd) 内的完成标记(最多列 5 个,位置为相对窗口起点的偏移)——定位"输出里混进的标记来自哪条命令"
691
- const hits = scanDoneMarkers(raw.slice(winStart, winEnd));
692
- log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口内含标记: ${hits.length === 0 ? "无" : hits.slice(0, 5).map((h) => `@${h.pos}=${h.seq ?? "?"}`).join(" ")}`);
781
+ const winEnd = boundary;
782
+ debug(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口: startPos=${winStart} endPos=${winEnd} 窗口长=${winEnd - winStart} markerSeq=${this._runningSeq} 是否退回整窗=${end <= 0 ? `是(定位失败, syntaxMode=${syntaxMode})` : "否"}`);
693
783
  // 诊断:窗口前 120 字符(仅 SSH_TOOL_DEBUG=1 时落盘)
694
784
  debug(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口前120: ${escapeCtl(raw.slice(0, 120))}`);
695
785
  if (end <= 0) {
@@ -697,16 +787,16 @@ export class BaseSession {
697
787
  // **不得退回整窗**——整窗会把重放的别会话命令行/标记当输出落盘(旧 bug:outLen=14019 全是杂物)。
698
788
  // 改为按窗口内已有标记边界裁剪:取"本命令自己的标记之前、所有非本 seq 标记之后"的边界,
699
789
  // 先把重放段整段切掉,再剥行首残余控制码噪音;定位仍失败也只剩边界之后的少量内容。
700
- const ownEnd = marker.done ? marker.pos : raw.length;
701
- const boundary = this._fallbackBoundary(raw, ownEnd);
702
- 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;
703
793
  // 边界裁剪后"再试一次定位":此时窗口已剔除重放段,回显残片可能恢复成可匹配的连续文本
704
794
  const retry = extractOutputStart(fallback, echoText, fallback.length, false);
705
795
  if (retry > 0)
706
796
  fallback = fallback.slice(retry);
707
797
  else
708
798
  fallback = this._stripLeadingControlNoise(fallback);
709
- 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}`);
710
800
  // 中断命令再截到首个 ^C 回显,避免输出残留 ^C 与探针后 shell 重印的提示符(与旧版中断收尾语义一致)
711
801
  if (this._interruptSent) {
712
802
  const intr = detectInterrupt(fallback, 0);
@@ -716,10 +806,15 @@ export class BaseSession {
716
806
  return this._stripProbeEchoes(stripMarkers(fallback));
717
807
  }
718
808
  let out;
719
- if (marker.done) {
720
- // 中断命令:完成标记由探针补发,输出窗口会多出 ^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 回显、重印提示符与探针回显;
721
816
  // 输出在首个 ^C 回显处截断,保留中断前程序输出(探针回显另由 _stripProbeEchoes 剔除)
722
- let endPos = marker.pos;
817
+ let endPos = boundary;
723
818
  if (this._interruptSent) {
724
819
  const intr = detectInterrupt(raw, end);
725
820
  if (intr.interrupted)
@@ -727,16 +822,18 @@ export class BaseSession {
727
822
  }
728
823
  out = raw.slice(end, endPos);
729
824
  }
730
- else if (syntaxMode) {
731
- // 语法错误:命令整行作废、无 marker → 输出 = 回显结束 → 窗口末尾(含整段错误块),再剥尾部重印提示符
732
- out = stripTrailingPromptLine(raw.slice(end), raw, this._promptRef);
733
- }
734
825
  else {
735
- const intr = detectInterrupt(raw, end);
736
- 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
+ }
737
836
  }
738
- if (this._adapter)
739
- out = out.replace(this._markerCmd(this._runningSeq), "");
740
837
  return this._stripProbeEchoes(stripMarkers(out.replace(/^[\r\n]+/, "")));
741
838
  }
742
839
  /**
@@ -794,9 +891,7 @@ export class BaseSession {
794
891
  // 诊断:本次窗口起点取自"自身找到的 lastMarkerPos"(无标记时为 0,即缓冲起点);
795
892
  // captureStart=裁剪后残留长度(exec 的实际捕获起点,与缓冲内容相关,仅供参考)
796
893
  const winStartPos = marker.done ? marker.pos : 0;
797
- log.info(`[capture] ${this._logTag} seq=${this._cmdSeq + 1} 起点=${winStartPos} captureStart=${this._buffer.length}(参考) entryCursor=${entryCursor} bufLen=${entryBufLen} lastMarkerPos=${marker.done ? marker.pos : -1}`);
798
- // 记录命令发起前缓冲末行(旧提示符行):语法错误收尾时 shell 会重印同形提示符,供输出尾部剥离
799
- this._promptRef = this._buffer.split("\n").pop() ?? "";
894
+ debug(`[capture] ${this._logTag} seq=${this._cmdSeq + 1} 起点=${winStartPos} captureStart=${this._buffer.length}(参考) entryCursor=${entryCursor} bufLen=${entryBufLen} lastMarkerPos=${marker.done ? marker.pos : -1}`);
800
895
  this._runningStartPos = 0;
801
896
  this._runningCommand = command;
802
897
  this._runningStartTs = Date.now();
@@ -805,7 +900,7 @@ export class BaseSession {
805
900
  this._runningSeq = this._cmdSeq;
806
901
  this._setBusy(true, "cmdStart");
807
902
  this._lastActive = Date.now();
808
- log.info(`[run] 开始 ${this._logTag} seq=${this._runningSeq} startPos=0 cmdLen=${command.length}`);
903
+ debug(`[run] 开始 ${this._logTag} seq=${this._runningSeq} startPos=0 cmdLen=${command.length}`);
809
904
  debug(`[run] 开始 ${this._logTag} seq=${this._runningSeq} cmd=${JSON.stringify(command.slice(0, 120))}`);
810
905
  this._emitStart(command, this._runningStartTs);
811
906
  }
@@ -832,7 +927,7 @@ export class BaseSession {
832
927
  if (text.includes(DONE_TAG)) {
833
928
  const found = scanDoneMarkers(text).map((h) => h.seq ?? "?").join(",");
834
929
  // 非内容信息保持 info(排障主力);命令/输出片段前缀(prefix)降级为 debug,避免泄漏内容
835
- log.info(`[chunk] ${this._logTag} seq=${this._runningSeq} runningStart=${this._runningStartPos} chunkLen=${text.length} found=[${found}]`);
930
+ debug(`[chunk] ${this._logTag} seq=${this._runningSeq} runningStart=${this._runningStartPos} chunkLen=${text.length} found=[${found}]`);
836
931
  debug(`[chunk] ${this._logTag} seq=${this._runningSeq} prefix=${escapeCtl(text.slice(0, 60))}`);
837
932
  }
838
933
  // 数据已落缓冲:通知数据到达监听器(agent 据此做节流合并推流;异常不打断数据写入)
@@ -854,8 +949,8 @@ export class BaseSession {
854
949
  * @returns 无
855
950
  */
856
951
  _sendBaselineMarker() {
857
- const baseline = this._markerCmd(0) + "\r";
858
- log.info(`[baseline] ${this._logTag} 初始化完成,发送基线标记 nonce=${this._nonce} seq=0 cmd=${JSON.stringify(this._markerCmd(0))}`);
952
+ const baseline = this._markerCmd(0) + this._ptyLineSep();
953
+ debug(`[baseline] ${this._logTag} 初始化完成,发送基线标记 nonce=${this._nonce} seq=0`);
859
954
  this._write(baseline);
860
955
  }
861
956
  /** 探测 shell 类型:按各适配器 probeCommand 逐个探测(POSIX 系优先,pwsh/cmd 用各自独有命令),
@@ -866,8 +961,16 @@ export class BaseSession {
866
961
  this._write(candidate.probeCommand + "\r");
867
962
  const output = await this._waitProbeOutput(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()));
868
963
  this._buffer = "";
869
- 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;
870
972
  return candidate;
973
+ }
871
974
  }
872
975
  }
873
976
  return null;
@@ -938,13 +1041,21 @@ export class BaseSession {
938
1041
  return;
939
1042
  }
940
1043
  try {
941
- // 完成判定(权威且唯一):命令后追加的 printf 输出 <SSH_DONE:seq:code>;marker 自带唯一 seq,从命令起点搜索
1044
+ // 真实完成(权威且唯一):只认本会话 nonce + 本 seq 的 SSH_DONE 标记。
1045
+ // 探针已改用独立 SSH_PROBE tag,二者不再混淆——语法误判时真实退出码不会被覆盖。
942
1046
  const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
943
1047
  if (marker.done) {
944
1048
  this._settleCompletion({ kind: "done", markerPos: marker.pos, exitCode: marker.exitCode });
945
1049
  return;
946
1050
  }
947
- // 语法/解析错误:整行命令作废(含同行拼装的完成标记也不会执行)→ 输出安静后按完成收尾
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
+ // 语法/解析错误:整行命令作废(含同行拼装的完成标记也不会执行)→ 输出安静后补发探针按完成收尾
948
1059
  if (detectSyntaxError(this._adapter, this._buffer, this._watchStartPos)) {
949
1060
  // 兜底 tick 不重置已挂的静默计时(否则每周期顺延、永不收尾);数据到达时才推进
950
1061
  if (fromData || this._syntaxTimer === null)
@@ -988,9 +1099,11 @@ export class BaseSession {
988
1099
  this._completeRunning(this._watchStartPos, o.markerPos ?? this._buffer.length, o.exitCode ?? null, this._watchCommand);
989
1100
  }
990
1101
  /**
991
- * 推进语法错误安静窗口(一次性定时器,非轮询):识别到语法错误后等输出停止增长 SYNTAX_QUIET_MS 再收尾,
992
- * 确保 pwsh 异步/批量渲染的多行错误块被完整收进输出窗口;自首次识别起上限 SYNTAX_MAX_WAIT_MS。
993
- * 数据到达(输出变化)时重置安静窗口;兜底 tick 不重置(见 _checkCompletion)。
1102
+ * 推进语法错误安静窗口(一次性定时器,非轮询):
1103
+ * 识别到语法错误后等输出停止增长 SYNTAX_QUIET_MS,确保 pwsh 异步/批量渲染的多行错误块完整收进输出窗口;
1104
+ * 到点**直接按语法错误收尾,不再补发探针**——探针会被写进仍在运行的程序 stdin(如 `cat >file` / REPL),
1105
+ * 且语法误判时会提前收尾、丢弃迟到的真实完成标记;尾部最多多 1 行提示符噪音,按设计接受。
1106
+ * 自首次识别起硬上限 SYNTAX_MAX_WAIT_MS;数据到达(输出变化)时重置安静窗口;兜底 tick 不重置(见 _checkCompletion)。
994
1107
  * @returns 无
995
1108
  */
996
1109
  _armSyntaxQuiet() {
@@ -1007,7 +1120,7 @@ export class BaseSession {
1007
1120
  }
1008
1121
  if (now - this._syntaxSeenAt >= SYNTAX_MAX_WAIT_MS) {
1009
1122
  this._clearSyntaxTimer();
1010
- this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
1123
+ this._settleSyntaxError();
1011
1124
  return;
1012
1125
  }
1013
1126
  const remain = Math.max(0, SYNTAX_QUIET_MS - (now - this._syntaxQuietAt));
@@ -1016,14 +1129,18 @@ export class BaseSession {
1016
1129
  this._syntaxTimer = null;
1017
1130
  if (this._runningStartPos === null)
1018
1131
  return;
1019
- try {
1020
- this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
1021
- }
1022
- catch (e) {
1023
- log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 语法静默收尾异常: err=${e instanceof Error ? e.message : String(e)}`);
1024
- }
1132
+ this._settleSyntaxError();
1025
1133
  }, remain);
1026
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
+ }
1027
1144
  /**
1028
1145
  * 断连收尾(由后台兜底 tick 判定 _connected=false 触发):命令视为结束(可能从未完成),
1029
1146
  * 补发 done 防 server busy 卡死;不写 history(输出窗口从未定案,无可信尾部可摘)。
@@ -1043,7 +1160,7 @@ export class BaseSession {
1043
1160
  }
1044
1161
  }
1045
1162
  /**
1046
- * 开始一次命令的后台追踪:记录兜底判定所需的捕获窗口起点/命令,并复位语法静默状态。
1163
+ * 开始一次命令的后台追踪:记录兜底判定所需的捕获窗口起点/命令,并复位语法错误状态。
1047
1164
  * 不做任何轮询——完成判定由数据到达事件驱动(见 `_onDataArrived`)。
1048
1165
  * @param startPos 捕获窗口起点(相对当前 buffer;submit 为 0,exec 转后台为 captureStart)
1049
1166
  * @param command 命令(history 记录用)
@@ -1082,7 +1199,7 @@ export class BaseSession {
1082
1199
  }
1083
1200
  catch (e) {
1084
1201
  // tick 内异常:记一行后继续下一轮监听(完成判定仍只认当前 seq 标记)
1085
- log.info(`[watch] ${this._logTag} seq=${this._runningSeq} tick 异常, 保持监听: err=${e instanceof Error ? e.message : String(e)}`);
1202
+ infoDedup(`tick-exc:${this._logTag}:${this._runningSeq}`, `[watch] ${this._logTag} seq=${this._runningSeq} tick 异常, 保持监听: err=${e instanceof Error ? e.message : String(e)}`);
1086
1203
  }
1087
1204
  }
1088
1205
  /** 清除 exec 同步等待的 animation 一次性定时器(幂等) */
@@ -1121,10 +1238,16 @@ export class BaseSession {
1121
1238
  log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 输出提取异常, 使用整窗输出: cmdLen=${command.length} err=${e instanceof Error ? e.message : String(e)}`);
1122
1239
  output = raw;
1123
1240
  }
1124
- 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
+ }
1125
1248
  this._emitDone(exitCode, Date.now(), output);
1126
1249
  this._cursor = Math.max(startPos, endPos);
1127
- this._setBusy(false, "cmdDone"); // 命令完成即清 busy("保持 busy 到排队序列结束"的机制已移除)
1250
+ this._setBusy(false, "cmdDone", true); // 命令完成即清 busy("保持 busy 到排队序列结束"的机制已移除);释放行紧随 [run] 结束,降 debug 免重复
1128
1251
  // 结束日志:非内容信息(长度/耗时/退出码/原因)保持 info;有界输出尾部摘要降级为 debug,避免泄漏输出内容
1129
1252
  log.info(`[run] 结束 ${this._logTag} seq=${this._runningSeq} endPos=${endPos} exitCode=${exitCode} outLen=${output.length} duration=${Date.now() - this._runningStartTs}ms reason=${reason}`);
1130
1253
  if (isDebugEnabled()) {