@xiaoqiong0v0/opencode-ssh-tool 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +27 -21
  2. package/dist/agent.d.ts +52 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +244 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/base-session.d.ts +95 -13
  7. package/dist/base-session.d.ts.map +1 -1
  8. package/dist/base-session.js +271 -102
  9. package/dist/base-session.js.map +1 -1
  10. package/dist/config.d.ts +6 -2
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +16 -7
  13. package/dist/config.js.map +1 -1
  14. package/dist/constants.d.ts +14 -15
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +14 -15
  17. package/dist/constants.js.map +1 -1
  18. package/dist/i18n.d.ts +24 -14
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +37 -12
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +90 -73
  24. package/dist/index.js.map +1 -1
  25. package/dist/last-match.d.ts +11 -0
  26. package/dist/last-match.d.ts.map +1 -1
  27. package/dist/last-match.js +24 -0
  28. package/dist/last-match.js.map +1 -1
  29. package/dist/local-session.d.ts +2 -1
  30. package/dist/local-session.d.ts.map +1 -1
  31. package/dist/local-session.js +14 -8
  32. package/dist/local-session.js.map +1 -1
  33. package/dist/server-entry.d.ts +2 -0
  34. package/dist/server-entry.d.ts.map +1 -0
  35. package/dist/server-entry.js +61 -0
  36. package/dist/server-entry.js.map +1 -0
  37. package/dist/server-manager.d.ts +9 -9
  38. package/dist/server-manager.d.ts.map +1 -1
  39. package/dist/server-manager.js +208 -27
  40. package/dist/server-manager.js.map +1 -1
  41. package/dist/server.d.ts +3 -28
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +429 -275
  44. package/dist/server.js.map +1 -1
  45. package/dist/session.d.ts +0 -1
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +2 -9
  48. package/dist/session.js.map +1 -1
  49. package/dist/shell-adapter.d.ts +38 -2
  50. package/dist/shell-adapter.d.ts.map +1 -1
  51. package/dist/shell-adapter.js +372 -24
  52. package/dist/shell-adapter.js.map +1 -1
  53. package/dist/utils.d.ts +3 -1
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js +29 -10
  56. package/dist/utils.js.map +1 -1
  57. package/dist/web/app.css +12 -7
  58. package/dist/web/app.js +11 -2
  59. package/dist/web/index.html +6 -3
  60. package/dist/web/xterm.css +218 -0
  61. package/package.json +3 -2
@@ -1,38 +1,100 @@
1
1
  // BaseSession:SSH(SshSession) 与本地(LocalSession) 终端的公共基类
2
2
  // 统一完成标记法:注入 PROMPT_COMMAND 脚本,命令完成时输出 <SSH_DONE:退出码>
3
3
  // 子类仅需实现:连接建立(connect)、数据写入(_write)、传输层清理(_closeTransport)、状态字段(getStatus)
4
- import { ANIMATION_WINDOW_MS, MAX_OUTPUT_LEN, RAW_LOG_MAX, SETTLE_TIMEOUT_MS, } from "./constants.js";
4
+ import { ANIMATION_WINDOW_MS, MAX_OUTPUT_LEN, RAW_LOG_MAX, DONE_TAG, } from "./constants.js";
5
5
  import log from "./log.js";
6
6
  import { SessionHistory } from "./history.js";
7
- import { toModelText, extractOutputStart, extractOutput } from "./utils.js";
8
- import { findLastEndOf } from "./last-match.js";
9
- import { detectLastDoneMarker, stripMarkers, resolveByProbe } from "./shell-adapter.js";
7
+ import { toModelText, extractOutputStart } from "./utils.js";
8
+ import { detectLastDoneMarker, stripMarkers, detectInterrupt, adapters } from "./shell-adapter.js";
9
+ import { tr } from "./i18n.js";
10
10
  /** 交互触发词(sudo 密码 / 分页器 / 确认提示),含中英文 */
11
- const INTERACTIVE_RE = /(\[sudo\] password for|password for \S+:|Password:|密码:|--More--|\(END\)|\[y\/N\]|\[Y\/n\]|yes\/no|是\/否)/i;
11
+ const INTERACTIVE_RE = /(\[sudo\] password for|password for \S+:|\*\*\*\s*$|Password:|密码:|--More--|\(END\)|\[y\/N\]|\[Y\/n\]|yes\/no|是\/否)/i;
12
12
  /** buffer 最大长度(未消费输出超限时截断头部,防内存膨胀) */
13
13
  const MAX_BUFFER_LEN = 2 * 1024 * 1024;
14
14
  /** 后台监听最长存活时间(防泄漏) */
15
15
  const MAX_WATCH_LEN = 10 * 60_000;
16
16
  /** Shell 探测等待超时 */
17
17
  const PROBE_TIMEOUT_MS = 5_000;
18
+ /**
19
+ * 剥离命令尾部注释:仅当 `#` 在行首或前有空白、且不在单双引号内(支持反斜杠转义)时视为注释,
20
+ * 避免 `echo "a#b"` / `echo 'a#b'` 引号内的 # 被误剥,也避免 marker 被真注释吞掉。
21
+ * @param s 命令文本
22
+ * @returns 剥离尾注释(若有)后的文本
23
+ */
24
+ function stripTrailingComment(s) {
25
+ let inSingle = false;
26
+ let inDouble = false;
27
+ let escaped = false;
28
+ for (let i = 0; i < s.length; i++) {
29
+ const c = s[i];
30
+ if (escaped) {
31
+ escaped = false;
32
+ continue;
33
+ }
34
+ if (c === "\\") {
35
+ escaped = true;
36
+ continue;
37
+ }
38
+ if (c === "'" && !inDouble) {
39
+ inSingle = !inSingle;
40
+ continue;
41
+ }
42
+ if (c === '"' && !inSingle) {
43
+ inDouble = !inDouble;
44
+ continue;
45
+ }
46
+ if (c === "#" && !inSingle && !inDouble && (i === 0 || /\s/.test(s[i - 1]))) {
47
+ return s.slice(0, i).trimEnd();
48
+ }
49
+ }
50
+ return s.trimEnd();
51
+ }
52
+ /**
53
+ * 剥离命令尾部注释与尾部运算符,避免拼装 `;marker` 时:
54
+ * - 尾注释 `# xxx` 把 marker 整行吞掉 → 检测不到完成
55
+ * - 尾运算符 `&`、`;`、`&&`、`||` 与拼接的 `;` 形成 `&;`/`;;` 等语法错误
56
+ * @param command 原始命令
57
+ * @returns 可安全拼装 marker 的命令体
58
+ */
59
+ function stripCommandTail(command) {
60
+ // 仅处理最后一行:多行命令(如 heredoc 正文)中间的 `#` 是内容,若在此截断会把命令切断
61
+ const nl = command.lastIndexOf("\n");
62
+ const head = nl >= 0 ? command.slice(0, nl + 1) : "";
63
+ const last = nl >= 0 ? command.slice(nl + 1) : command;
64
+ let s = stripTrailingComment(last);
65
+ s = s.replace(/(?:&&|\|\||[;&|])[\s]*$/, "");
66
+ return (head + s).trimEnd();
67
+ }
18
68
  /** 会话命令执行与读取的公共实现 */
19
69
  export class BaseSession {
20
70
  sessionID;
21
71
  name;
72
+ _lang;
22
73
  _connected = false;
23
74
  _remoteBusy = false;
75
+ /**
76
+ * 是否保持 busy:排队命令顺序执行时,中间某条完成也不清 busy,
77
+ * 直到序列最后一条完成(否则 web/status 会在中间命令间隙闪 busy=false)。
78
+ */
79
+ _holdBusy = false;
24
80
  _buffer = "";
25
81
  _connectedAt = 0;
26
82
  _lastActive = 0;
27
83
  _runningStartPos = null;
28
84
  _runningCommand = "";
29
85
  _watchTimer = null;
86
+ /** 命令序号(完成标记 <SSH_DONE:seq:code> 标识本次命令,防残留误判) */
87
+ _cmdSeq = 0;
88
+ /** 当前命令序号(_beginCapture 递增,供检测/拼接用) */
89
+ _runningSeq = 0;
30
90
  /** 下次捕获窗口起点(相对当前 buffer):前一条命令结束后,标记/残留从此处开始 */
31
91
  _cursor = 0;
32
92
  /** WS 实时流已发送位置(相对 buffer 偏移);null = 尚未定位命令回显结束点 */
33
93
  _streamPos = null;
34
94
  /** Shell 适配器(探测后确定) */
35
95
  _adapter = null;
96
+ /** 多行命令写入 PTY 的行分隔符:Unix PTY 用 \n;Windows ConPTY(本地会话)用 \r(裸 \n 不提交行) */
97
+ _lineSep = "\n";
36
98
  _closed = false;
37
99
  /** 当前运行命令的输入时刻(history 记录展示命令发起时间用) */
38
100
  _runningStartTs = 0;
@@ -43,9 +105,19 @@ export class BaseSession {
43
105
  /** 累积原始字节总数(单调递增,前端增量游标) */
44
106
  _rawTotal = 0;
45
107
  _history;
46
- constructor(sessionID, history, name = "default") {
108
+ /** 生命周期监听器(命令开始/完成事件,agent 转报 server) */
109
+ _lifecycle = null;
110
+ /**
111
+ * 本次命令期间是否真实发送过中断(Ctrl-C)。
112
+ * 中断判定不依赖"命令回显定位":超宽命令被 readline 折行重绘时回显会多出字符,
113
+ * 精确匹配必然失败(echoEnd=0),若再以 echoEnd 为前置条件则 ^C 永远检测不到 → busy 卡死。
114
+ * 仅当确实发过 ^C 才去缓冲区找 ^C 回显,天然排除历史残留 ^C 的误判。
115
+ */
116
+ _interruptSent = false;
117
+ constructor(sessionID, history, name = "default", _lang = "en") {
47
118
  this.sessionID = sessionID;
48
119
  this.name = name;
120
+ this._lang = _lang;
49
121
  this._history = history;
50
122
  }
51
123
  /** 结果中的类型专属字段(SSH 附加 host,本地为空) */
@@ -57,11 +129,13 @@ export class BaseSession {
57
129
  */
58
130
  async submit(command) {
59
131
  if (!this._ready())
60
- return { ok: false, output: "", error: "Not connected" };
132
+ return { ok: false, output: "", error: tr("err_not_connected", this._lang) };
61
133
  if (this._remoteBusy)
62
- return { ok: false, output: "", error: `Previous command still running, poll with ${this._statusCmd} first` };
134
+ return { ok: false, output: "", error: tr("cmd_busy", this._lang) };
135
+ if (this._adapter?.hasOpenContinuation(command))
136
+ return { ok: false, output: "", error: tr("cmd_continuation", this._lang) };
63
137
  this._beginCapture(command);
64
- this._write(command + "\r");
138
+ this._write(this._composeCommand(command));
65
139
  this._startBackgroundWatch(0, command);
66
140
  return { ok: true, output: "", submitted: true, command, duration: 0, ...this._extraResult };
67
141
  }
@@ -73,29 +147,39 @@ export class BaseSession {
73
147
  async exec(command) {
74
148
  const startTs = Date.now();
75
149
  if (!this._ready())
76
- return { ok: false, output: "", error: "Not connected" };
77
- if (this._remoteBusy)
78
- return { ok: false, output: "", error: `Previous command still running, poll with ${this._statusCmd} first` };
150
+ return { ok: false, output: "", error: tr("err_not_connected", this._lang) };
151
+ if (this._remoteBusy) {
152
+ log.info(`exec quick-fail busy: ${command} (busy=${this._remoteBusy})`);
153
+ return { ok: false, output: "", error: tr("cmd_busy", this._lang) };
154
+ }
155
+ if (this._adapter?.hasOpenContinuation(command)) {
156
+ log.info(`exec quick-fail open continuation: ${command}`);
157
+ return { ok: false, output: "", error: tr("cmd_continuation", this._lang) };
158
+ }
79
159
  this._beginCapture(command);
80
160
  const captureStart = this._buffer.length;
81
- this._write(command + "\r");
161
+ this._write(this._composeCommand(command));
82
162
  const outcome = await this._waitCompletion(0, startTs);
163
+ log.info(`exec ${command} -> ${outcome.kind}${outcome.kind === "running" ? "" : " markerPos=" + outcome.markerPos} (busy=${this._remoteBusy})`);
83
164
  switch (outcome.kind) {
84
165
  case "done": {
85
- this._remoteBusy = false;
166
+ this._remoteBusy = this._holdBusy; // 排队序列中间命令:保持 busy 到序列结束
86
167
  const raw = this._buffer.slice(captureStart, outcome.markerPos ?? this._buffer.length);
87
168
  this._cursor = Math.max(0, outcome.markerPos ?? this._buffer.length);
88
- this._history.append(command, extractOutput(raw, command), this._runningStartTs, Date.now());
169
+ const out = this._extractOutput(raw, command);
170
+ this._history.append(command, out, this._runningStartTs, Date.now());
171
+ this._emitDone(outcome.exitCode ?? null, Date.now(), out);
89
172
  this._clearRunningContext();
90
- return { ok: true, output: this._truncate(toModelText(extractOutput(raw, command))), command, duration: Date.now() - startTs, ...this._extraResult };
173
+ return { ok: true, output: this._truncate(toModelText(out)), command, duration: Date.now() - startTs, ...this._extraResult };
91
174
  }
92
175
  case "interactive": {
93
- this._remoteBusy = false;
176
+ // 交互程序(sudo 密码 / 确认提示):命令仍在等待用户输入,
177
+ // 保留 running 上下文与 busy 状态,转后台 watch 持续监听完成标记;
178
+ // 用户 send 输入后命令完成时由 watch 完整收集输出并翻转 busy(防新命令被当 exec 塞进运行中 shell)
94
179
  const raw = this._buffer.slice(captureStart);
95
- this._cursor = this._buffer.length;
96
- this._history.append(command, extractOutput(raw, command), this._runningStartTs, Date.now());
97
- this._clearRunningContext();
98
- return { ok: true, output: this._truncate(toModelText(extractOutput(raw, command))), interactive: true, command, duration: Date.now() - startTs, ...this._extraResult };
180
+ const out = this._extractOutput(raw, command);
181
+ this._startBackgroundWatch(captureStart, command);
182
+ return { ok: true, output: this._truncate(toModelText(out)), interactive: true, command, duration: Date.now() - startTs, ...this._extraResult };
99
183
  }
100
184
  case "running": {
101
185
  this._runningStartPos = 0;
@@ -125,13 +209,10 @@ export class BaseSession {
125
209
  */
126
210
  async readBuffer() {
127
211
  if (!this._connected)
128
- return { ok: false, output: "", error: "Not connected" };
212
+ return { ok: false, output: "", error: tr("err_not_connected", this._lang) };
129
213
  let out;
130
214
  if (this._runningStartPos !== null && this._runningCommand) {
131
- const end = extractOutputStart(this._buffer.slice(this._runningStartPos), this._runningCommand);
132
- const marker = end > 0
133
- ? detectLastDoneMarker(this._buffer, this._runningStartPos + end)
134
- : { done: false, exitCode: 0, pos: 0 };
215
+ const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._runningSeq);
135
216
  if (marker.done) {
136
217
  const raw = this._buffer.slice(this._runningStartPos, marker.pos);
137
218
  this._buffer = this._buffer.slice(marker.pos);
@@ -158,7 +239,7 @@ export class BaseSession {
158
239
  */
159
240
  send(text) {
160
241
  if (!this._connected)
161
- return { ok: false, error: "Not connected" };
242
+ return { ok: false, error: tr("err_not_connected", this._lang) };
162
243
  const payload = text
163
244
  .replace(/\\x1b/gi, "\x1b")
164
245
  .replace(/\\x03/gi, "\x03")
@@ -166,6 +247,10 @@ export class BaseSession {
166
247
  .replace(/\\x1a/gi, "\x1a")
167
248
  .replace(/\\r/g, "\r")
168
249
  .replace(/\\n/g, "\r");
250
+ log.info(`send -> ${JSON.stringify(payload)} (connected=${this._connected}, busy=${this._remoteBusy})`);
251
+ // 真实中断输入:标记本次命令已发过 Ctrl-C,供完成判定识别 ^C 回显(不依赖命令回显定位)
252
+ if (payload.includes("\x03"))
253
+ this._interruptSent = true;
169
254
  this._write(payload);
170
255
  this._lastActive = Date.now();
171
256
  return { ok: true };
@@ -179,6 +264,37 @@ export class BaseSession {
179
264
  getRunningCommand() {
180
265
  return this._runningStartPos !== null && this._connected ? this._runningCommand : "";
181
266
  }
267
+ /**
268
+ * 设置命令生命周期监听器(命令开始/完成时回调)
269
+ * @param listener 回调(start 携带命令,done 携带退出码与结束时刻;null 表示清除)
270
+ */
271
+ setLifecycle(listener) {
272
+ this._lifecycle = listener;
273
+ }
274
+ /**
275
+ * 设置是否保持 busy(排队命令序列期间为 true):
276
+ * true 时中间命令完成不清 busy;置 false 时立即清 busy。
277
+ * @param hold 是否保持
278
+ */
279
+ setHoldBusy(hold) {
280
+ this._holdBusy = hold;
281
+ if (!hold)
282
+ this._remoteBusy = false;
283
+ }
284
+ /** 触发命令开始事件 */
285
+ _emitStart(command, ts) {
286
+ try {
287
+ this._lifecycle?.({ type: "start", command, ts });
288
+ }
289
+ catch { /* 监听器异常不打断执行 */ }
290
+ }
291
+ /** 触发命令完成事件 */
292
+ _emitDone(exitCode, endTs, output) {
293
+ try {
294
+ this._lifecycle?.({ type: "done", exitCode, endTs, output });
295
+ }
296
+ catch { /* 同上 */ }
297
+ }
182
298
  hasRunningStream() {
183
299
  return this._runningStartPos !== null && this._connected && this._runningCommand !== "";
184
300
  }
@@ -192,18 +308,23 @@ export class BaseSession {
192
308
  this._streamPos = null;
193
309
  return { data: "", done: false };
194
310
  }
195
- const end = extractOutputStart(this._buffer.slice(this._runningStartPos), this._runningCommand);
311
+ // 完成标记先定位(marker 唯一 seq,从命令起点搜索,免疫 echoEnd 被后序回显带偏)
312
+ const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._runningSeq);
313
+ const markerEnd = marker.done ? marker.pos : this._buffer.length;
314
+ const end = extractOutputStart(this._buffer.slice(this._runningStartPos), this._composeEchoText(this._runningCommand), markerEnd - this._runningStartPos);
196
315
  if (end <= 0)
197
316
  return { data: "", done: false };
198
317
  const echoEnd = this._runningStartPos + end;
199
- const marker = detectLastDoneMarker(this._buffer, echoEnd);
200
318
  const windowEnd = marker.done ? marker.pos : this._buffer.length;
201
319
  if (this._streamPos === null) {
202
320
  this._streamPos = echoEnd;
203
321
  }
204
322
  const raw = this._buffer.slice(this._streamPos, windowEnd);
205
323
  this._streamPos = windowEnd;
206
- return { data: stripMarkers(raw), done: marker.done };
324
+ let data = stripMarkers(raw);
325
+ if (this._adapter)
326
+ data = data.replace(this._markerCmd(this._runningSeq), "");
327
+ return { data, done: marker.done };
207
328
  }
208
329
  /** 关闭终端,清理会话态(幂等) */
209
330
  close() {
@@ -224,11 +345,86 @@ export class BaseSession {
224
345
  const from = pos - this._rawDiscarded;
225
346
  return { data: this._rawLog.slice(from), pos: this._rawTotal };
226
347
  }
348
+ /**
349
+ * 本会话当前命令的完成标记命令(adapter 负责各自的标记语法,无 adapter 时用 POSIX 默认)
350
+ * @param seq 命令序号
351
+ * @returns 标记命令文本
352
+ */
353
+ _markerCmd(seq) {
354
+ return this._adapter ? this._adapter.markerCmd(seq) : `printf '\\n${DONE_TAG}${seq}:%s>' $?`;
355
+ }
356
+ /**
357
+ * 组合实际写入 PTY 的命令:在规范形式(`_composeEchoText`)基础上把换行改写为会话行分隔符并补尾部回车。
358
+ * 行分隔符按会话类型:Unix PTY 用 `\n`(正常提交行),Windows ConPTY 用 `\r`(实测裸 `\n` 不提交行,
359
+ * pwsh 会停在续行 `>>`、行序错乱)。
360
+ * @param command 原始命令(history 存干净版本)
361
+ * @returns 实际写入 PTY 的文本(尾部带 \r,行分隔已按会话类型规范化)
362
+ */
363
+ _composeCommand(command) {
364
+ return this._composeEchoText(command).replace(/\n/g, this._lineSep) + "\r";
365
+ }
366
+ /**
367
+ * 组合命令文本(规范/匹配用形式,始终以 `\n` 分隔行、无尾部回车):命令 + 完成标记。
368
+ * 该形式供 `extractOutputStart` 按 `\n` 切行并做段间宽松匹配(跳过 `>>` 续行提示),
369
+ * 故不可改写为 `_lineSep`——否则本地会话(`\r`)下整段回显变一行、段间匹配失效、回显剥离失败。
370
+ * 完成标记用当前 shell 的语句分隔符同行拼接(POSIX/pwsh 为 `;`,cmd 为 `&`),免疫 prompt 框架覆盖:
371
+ * `python; printf ...` 中 printf 由 shell 在 python 退出后执行,不会被 REPL 当 stdin 消费
372
+ * (实测 top/read/python 均正常出 marker)。
373
+ * 多行命令用 shell 组语法(groupWrap)包裹成**一条**命令:一次输入 = 一条命令 = 一段输出,
374
+ * 避免 shell 逐行执行导致回显与输出交错、中间输出被 extractOutputStart 丢掉
375
+ * (如 `cd /tmp` 之后的 `echo AAA`/`echo BBB` 输出丢失);heredoc 分隔符行必须独占一行,
376
+ * 若标记直接拼上去(`EOF ;printf ...`)会让分隔符失效 → heredoc 永不结束 → 卡死,
377
+ * 故含 heredoc 的多行命令也必须包组。
378
+ * cmd 无法安全包组(组内 `%errorlevel%` 为解析期展开、退出码失真)或无 adapter 时回退原样拼接。
379
+ * 交互程序(top/vi/read/python 等)期间保留 running 上下文与 busy,marker 出现即判定完成。
380
+ * @param command 原始命令
381
+ * @returns 命令 + 标记文本(规范 `\n` 形式,无尾部回车)
382
+ */
383
+ _composeEchoText(command) {
384
+ const marker = this._markerCmd(this._runningSeq);
385
+ const body = stripCommandTail(command);
386
+ const sep = this._adapter?.stmtSep ?? ";";
387
+ // 多行命令:尝试包组(cmd 返回 null → 回退原样拼接),使整段作为一条命令执行
388
+ if (body.includes("\n")) {
389
+ const grouped = this._adapter?.groupWrap(body);
390
+ return grouped ? `${grouped} ${sep}${marker}` : `${body} ${sep}${marker}`;
391
+ }
392
+ return `${body} ${sep}${marker}`;
393
+ }
394
+ /**
395
+ * 提取命令纯输出:从命令回显后切到完成标记(无标记则切到中断回显 ^C 处),
396
+ * 顺带去掉追加的标记命令回显行。
397
+ * 回显定位使用组合命令文本(command + 标记段)而非裸命令:裸命令可能出现在程序错误输出中
398
+ * (如 `pw` 的错误行 `bash: pw: command not found`),而组合命令整行回显是唯一的,
399
+ * 可避免 last-match 误命中错误行导致输出被裁空。
400
+ * @param raw 本次执行窗口原始字节流
401
+ * @param command 原始命令(历史用)
402
+ * @returns 纯程序输出原始流
403
+ */
404
+ _extractOutput(raw, command) {
405
+ // 完成标记先定位:marker 自带唯一 seq,从窗口起点搜索即可(echoEnd 可能被连续输入的后序回显带偏)
406
+ const marker = detectLastDoneMarker(raw, 0, this._runningSeq);
407
+ const echoText = this._composeEchoText(command);
408
+ const end = marker.done ? extractOutputStart(raw, echoText, marker.pos) : extractOutputStart(raw, echoText);
409
+ if (end <= 0)
410
+ return stripMarkers(raw);
411
+ let out;
412
+ if (marker.done) {
413
+ out = raw.slice(end, marker.pos);
414
+ }
415
+ else {
416
+ const intr = detectInterrupt(raw, end);
417
+ out = intr.interrupted ? raw.slice(end, intr.pos) : raw.slice(end);
418
+ }
419
+ if (this._adapter)
420
+ out = out.replace(this._markerCmd(this._runningSeq), "");
421
+ return stripMarkers(out.replace(/^[\r\n]+/, ""));
422
+ }
227
423
  /** 命令执行/提交前准备:裁剪已消费缓冲 + 丢弃上一条命令的迟到残留标记 */
228
424
  _beginCapture(command) {
229
- // 用上一条命令完成标记(最后一个合法 <SSH_DONE:N>)定位命令起点;
425
+ // 用上一条命令完成标记(<SSH_DONE:seq:N>,此刻 _runningSeq 仍是上一条的)定位命令起点;
230
426
  // 备屏重放等场景下 buffer 可能含旧标记,裁剪到最后一个标记之后即可
231
- const marker = detectLastDoneMarker(this._buffer, 0);
427
+ const marker = detectLastDoneMarker(this._buffer, 0, this._runningSeq);
232
428
  if (marker.done) {
233
429
  this._buffer = this._buffer.slice(marker.pos);
234
430
  this._cursor = 0;
@@ -239,8 +435,12 @@ export class BaseSession {
239
435
  this._runningStartPos = 0;
240
436
  this._runningCommand = command;
241
437
  this._runningStartTs = Date.now();
438
+ this._interruptSent = false; // 新命令:清除上一条/空闲期发出的 Ctrl-C 标记
439
+ this._cmdSeq += 1;
440
+ this._runningSeq = this._cmdSeq;
242
441
  this._remoteBusy = true;
243
442
  this._lastActive = Date.now();
443
+ this._emitStart(command, this._runningStartTs);
244
444
  }
245
445
  /** 追加输出字节:维护业务缓冲 + 连续原始流 ring */
246
446
  _appendBuffer(text) {
@@ -262,17 +462,17 @@ export class BaseSession {
262
462
  this._rawDiscarded += trimmed;
263
463
  }
264
464
  }
265
- /** 探测 shell 类型:deadline 内循环发送探测命令并解析输出(shell 慢启动时也能等到就绪) */
465
+ /** 探测 shell 类型:按各适配器 probeCommand 逐个探测(POSIX 系优先,pwsh/cmd 用各自独有命令),
466
+ * login shell 的 $0 带 - 前缀(-bash/-zsh)由对应 parseProbe 容忍 */
266
467
  async _probeShell(deadline) {
267
468
  while (Date.now() < deadline) {
268
- this._write("echo __SHELL_ID__$0\r");
269
- const output = await this._waitProbeOutput(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()));
270
- if (output !== null && /\b__SHELL_ID__\b/.test(output)) {
271
- const adapter = resolveByProbe(output);
469
+ for (const candidate of adapters) {
470
+ this._write(candidate.probeCommand + "\r");
471
+ const output = await this._waitProbeOutput(Math.min(PROBE_TIMEOUT_MS, deadline - Date.now()));
272
472
  this._buffer = "";
273
- return adapter;
473
+ if (output !== null && candidate.parseProbe(output))
474
+ return candidate;
274
475
  }
275
- this._buffer = "";
276
476
  }
277
477
  return null;
278
478
  }
@@ -292,45 +492,9 @@ export class BaseSession {
292
492
  }
293
493
  return this._buffer.length > 0 ? this._buffer : null;
294
494
  }
295
- /**
296
- * deadline 内循环注入+等待确认,成功返回 true,超时返回 false
297
- * 逐行发送避免因 shell 未就绪导致的整块丢失;每轮等待最大 3s
298
- * 注入命令原样回显(不抑制),确认以 echo 唯一标识为准,避免与后续命令时序混淆
299
- */
300
- async _injectAndSettle(deadline) {
301
- const TOKEN = "__SSH_INJECT_DONE__";
302
- const line = this._adapter.buildInjectScript().trim();
303
- if (!line)
304
- return true;
305
- while (Date.now() < deadline) {
306
- this._write(line + "\r");
307
- if (await this._waitForBufferToken(line, TOKEN, Math.min(SETTLE_TIMEOUT_MS, deadline - Date.now()))) {
308
- await new Promise((r) => setTimeout(r, 100));
309
- this._buffer = "";
310
- this._cursor = 0;
311
- return true;
312
- }
313
- log.info("注入标识未确认,重试");
314
- this._buffer = "";
315
- this._cursor = 0;
316
- }
317
- return false;
318
- }
319
- /** 等待缓冲中出现指定文本(最多 maxMs) */
320
- async _waitForBufferToken(line, token, maxMs) {
321
- const startTime = Date.now();
322
- while (Date.now() - startTime < maxMs) {
323
- const lineEnd = findLastEndOf(line, this._buffer);
324
- if (lineEnd >= 0 && this._buffer.includes(token, lineEnd + 1))
325
- return true;
326
- await new Promise((r) => setTimeout(r, 50));
327
- }
328
- return false;
329
- }
330
495
  _waitCompletion(startPos, startTs) {
331
496
  return new Promise((resolve) => {
332
497
  let lastLen = this._buffer.length;
333
- let echoEnd = 0;
334
498
  const timer = setInterval(() => {
335
499
  if (INTERACTIVE_RE.test(this._buffer.slice(startPos))) {
336
500
  clearInterval(timer);
@@ -341,23 +505,25 @@ export class BaseSession {
341
505
  const curLen = this._buffer.length;
342
506
  if (curLen !== lastLen)
343
507
  lastLen = curLen;
344
- if (echoEnd <= 0 && this._runningCommand) {
345
- const end = extractOutputStart(this._buffer.slice(startPos), this._runningCommand);
346
- if (end > 0)
347
- echoEnd = startPos + end;
508
+ // 完成判定(权威):命令后追加的 printf 输出 <SSH_DONE>,或 Ctrl-C 中断时 TTY 回显 ^C。
509
+ // marker 自带唯一 seq,从命令起点直接搜索即可(不依赖命令回显定位)
510
+ const marker = detectLastDoneMarker(this._buffer, startPos, this._runningSeq);
511
+ if (marker.done) {
512
+ clearInterval(timer);
513
+ resolve({ kind: "done", markerPos: marker.pos, exitCode: marker.exitCode });
514
+ return;
348
515
  }
349
- // 完成标记(精确权威):命令结束时 shell 重绘提示符前必输出 <SSH_DONE>,
350
- // 下载/编译等静默期不会误判完成,中断(Ctrl-C 回到提示符)同样会输出标记。
351
- // 必须等命令回显出现后才检测:回显前的标记只可能是上一条命令的迟到残留。
352
- if (echoEnd > 0) {
353
- const marker = detectLastDoneMarker(this._buffer, echoEnd);
354
- if (marker.done) {
516
+ // ^C 中断:仅当本次命令确实发送过 Ctrl-C 才检测(无需命令回显定位,
517
+ // 超宽命令折行重绘会让回显精确匹配失败;未发过 Ctrl-C 时缓冲区里的 ^C 只可能是残留)
518
+ if (this._interruptSent) {
519
+ const intr = detectInterrupt(this._buffer, startPos);
520
+ if (intr.interrupted) {
355
521
  clearInterval(timer);
356
- resolve({ kind: "done", markerPos: marker.pos });
522
+ resolve({ kind: "done", markerPos: intr.pos, exitCode: 130 });
357
523
  return;
358
524
  }
359
525
  }
360
- // 超过动画窗口仍未出现完成标记 → 交给后台 watch 继续监听(不设硬超时)
526
+ // 超过动画窗口仍未出现完成信号 → 交给后台 watch 继续监听(不设硬超时)
361
527
  if (now - startTs >= ANIMATION_WINDOW_MS) {
362
528
  clearInterval(timer);
363
529
  resolve({ kind: "running" });
@@ -366,33 +532,35 @@ export class BaseSession {
366
532
  }, 50);
367
533
  });
368
534
  }
369
- /** 后台监听:轮询 done 标记 → 收集输出进 history + busy=false */
535
+ /** 后台监听:轮询 done 标记 / ^C 中断 → 收集输出进 history + busy=false */
370
536
  _startBackgroundWatch(startPos, command) {
371
537
  if (this._watchTimer)
372
538
  clearInterval(this._watchTimer);
373
539
  const born = Date.now();
374
- let echoEnd = 0;
375
540
  this._watchTimer = setInterval(() => {
376
541
  if (!this._connected || Date.now() - born > MAX_WATCH_LEN) {
542
+ // 超时/断连:命令视为结束(可能从未完成),补发 done 防 server busy 卡死
543
+ if (this._runningCommand)
544
+ this._emitDone(null, Date.now());
377
545
  this._remoteBusy = false;
378
546
  this._clearRunningContext();
379
547
  if (this._watchTimer)
380
548
  clearInterval(this._watchTimer);
381
549
  return;
382
550
  }
383
- if (echoEnd <= 0 && command) {
384
- const end = extractOutputStart(this._buffer.slice(startPos), command);
385
- if (end > 0)
386
- echoEnd = startPos + end;
387
- }
388
- if (echoEnd <= 0)
389
- return;
390
- const marker = detectLastDoneMarker(this._buffer, echoEnd);
391
- if (marker.done) {
392
- const raw = this._buffer.slice(startPos, marker.pos);
393
- this._history.append(command, extractOutput(raw, command), this._runningStartTs);
394
- this._cursor = Math.max(startPos, marker.pos);
395
- this._remoteBusy = false;
551
+ // marker 唯一 seq,从命令起点搜索(不依赖命令回显定位)
552
+ const marker = detectLastDoneMarker(this._buffer, startPos, this._runningSeq);
553
+ // ^C 中断:仅当本次命令确实发送过 Ctrl-C 才检测(超宽命令折行重绘会让回显精确匹配失败)
554
+ const intr = this._interruptSent ? detectInterrupt(this._buffer, startPos) : { interrupted: false, pos: 0 };
555
+ const end = marker.done ? marker.pos : intr.pos;
556
+ if (marker.done || intr.interrupted) {
557
+ const raw = this._buffer.slice(startPos, end);
558
+ const code = marker.done ? marker.exitCode : 130;
559
+ const out = this._extractOutput(raw, command);
560
+ this._history.append(command, out, this._runningStartTs);
561
+ this._emitDone(code, Date.now(), out);
562
+ this._cursor = Math.max(startPos, end);
563
+ this._remoteBusy = this._holdBusy; // 排队序列中间命令:保持 busy 到序列结束
396
564
  this._clearRunningContext();
397
565
  if (this._watchTimer)
398
566
  clearInterval(this._watchTimer);
@@ -404,6 +572,7 @@ export class BaseSession {
404
572
  this._runningCommand = "";
405
573
  this._streamPos = null;
406
574
  this._runningStartTs = 0;
575
+ this._interruptSent = false;
407
576
  }
408
577
  _truncate(s) {
409
578
  if (s.length <= MAX_OUTPUT_LEN)