@xiaoqiong0v0/opencode-ssh-tool 0.6.11 → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/agent.d.ts +2 -2
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +124 -24
- package/dist/agent.js.map +1 -1
- package/dist/base-session.d.ts +134 -24
- package/dist/base-session.d.ts.map +1 -1
- package/dist/base-session.js +479 -187
- package/dist/base-session.js.map +1 -1
- package/dist/constants.d.ts +62 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +64 -1
- package/dist/constants.js.map +1 -1
- package/dist/escapes.d.ts +9 -0
- package/dist/escapes.d.ts.map +1 -0
- package/dist/escapes.js +65 -0
- package/dist/escapes.js.map +1 -0
- package/dist/i18n.d.ts +21 -5
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +7 -3
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/local-session.d.ts.map +1 -1
- package/dist/local-session.js +2 -0
- package/dist/local-session.js.map +1 -1
- package/dist/log.d.ts +9 -0
- package/dist/log.d.ts.map +1 -1
- package/dist/log.js +28 -0
- package/dist/log.js.map +1 -1
- package/dist/server-entry.js +24 -14
- package/dist/server-entry.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +310 -23
- package/dist/server.js.map +1 -1
- package/dist/session-store.d.ts +17 -2
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +78 -9
- package/dist/session-store.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +16 -2
- package/dist/session.js.map +1 -1
- package/dist/shell-adapter.d.ts +13 -4
- package/dist/shell-adapter.d.ts.map +1 -1
- package/dist/shell-adapter.js +23 -17
- package/dist/shell-adapter.js.map +1 -1
- package/dist/utils.d.ts +16 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +152 -27
- package/dist/utils.js.map +1 -1
- package/dist/web/app.css +6 -4
- package/dist/web/app.js +9 -9
- package/dist/web/index.html +4 -1
- package/package.json +1 -1
package/dist/base-session.js
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
// 统一完成标记法:注入 PROMPT_COMMAND 脚本,命令完成时输出 <SSH_DONE:退出码>
|
|
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, INTERRUPT_PROBE_INTERVAL_DEFAULT_SEC, } from "./constants.js";
|
|
6
|
-
import log, { debug, tailLines, tailSummary } from "./log.js";
|
|
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";
|
|
7
|
+
import { decodeTerminalEscapes } from "./escapes.js";
|
|
7
8
|
import { SessionHistory } from "./history.js";
|
|
8
|
-
import { toModelText, extractOutputStart, stripTrailingPromptLine } from "./utils.js";
|
|
9
|
+
import { toModelText, extractOutputStart, stripTrailingPromptLine, ECHO_REGEX_MAX_CMD_CHARS, echoRegexSourceLength } from "./utils.js";
|
|
9
10
|
import { detectLastDoneMarker, stripMarkers, detectInterrupt, detectSyntaxError, adapters } from "./shell-adapter.js";
|
|
10
11
|
import { tr } from "./i18n.js";
|
|
11
|
-
/** 交互触发词(sudo 密码 / 分页器 / 确认提示),含中英文 */
|
|
12
|
-
const INTERACTIVE_RE = /(\[sudo\] password for|password for \S+:|\*\*\*\s*$|Password:|密码:|--More--|\(END\)|\[y\/N\]|\[Y\/n\]|yes\/no|是\/否)/i;
|
|
13
12
|
/** buffer 最大长度(未消费输出超限时截断头部,防内存膨胀) */
|
|
14
13
|
const MAX_BUFFER_LEN = 2 * 1024 * 1024;
|
|
15
14
|
/** Shell 探测等待超时 */
|
|
@@ -64,6 +63,50 @@ function stripCommandTail(command) {
|
|
|
64
63
|
s = s.replace(/(?:&&|\|\||[;&|])[\s]*$/, "");
|
|
65
64
|
return (head + s).trimEnd();
|
|
66
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* 诊断辅助:转义控制字符,使日志单行可读(\r\n\t 转义、ESC 写成 \x1b)。
|
|
68
|
+
* 仅供日志打印使用,不参与任何控制流。
|
|
69
|
+
* @param s 原始字符串
|
|
70
|
+
* @returns 控制字符已转义的字符串
|
|
71
|
+
*/
|
|
72
|
+
function escapeCtl(s) {
|
|
73
|
+
return s.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\x1b/g, "\\x1b");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 扫描文本中所有完成标记(`<SSH_DONE:nonce:seq:code>` / `<SSH_DONE:seq:code>` / `<SSH_DONE:code>`)。
|
|
77
|
+
* 宽松解析(不要求退出码为纯数字):既覆盖求值式标记,也覆盖**命令行回显**里的标记片段
|
|
78
|
+
* (重放内容中常见 `<SSH_DONE:2:$LASTEXITCODE>` 这种未求值的回显)。
|
|
79
|
+
* 用途:① 诊断日志(窗口内混入的标记来自哪条命令);② 定位失败时按标记边界裁剪窗口。
|
|
80
|
+
* @param s 待扫描文本
|
|
81
|
+
* @returns 命中列表(按出现顺序)
|
|
82
|
+
*/
|
|
83
|
+
function scanDoneMarkers(s) {
|
|
84
|
+
const out = [];
|
|
85
|
+
let from = 0;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const i = s.indexOf(DONE_TAG, from);
|
|
88
|
+
if (i < 0)
|
|
89
|
+
break;
|
|
90
|
+
const gt = s.indexOf(">", i + DONE_TAG.length);
|
|
91
|
+
if (gt < 0)
|
|
92
|
+
break; // 未闭合:后续不再可能有完整标记
|
|
93
|
+
const segs = s.slice(i + DONE_TAG.length, gt).split(":");
|
|
94
|
+
let nonce = "";
|
|
95
|
+
let seq = null;
|
|
96
|
+
if (segs.length >= 3) {
|
|
97
|
+
nonce = segs[0];
|
|
98
|
+
const n = Number.parseInt(segs[1], 10);
|
|
99
|
+
seq = Number.isNaN(n) ? null : n;
|
|
100
|
+
}
|
|
101
|
+
else if (segs.length === 2) {
|
|
102
|
+
const n = Number.parseInt(segs[0], 10);
|
|
103
|
+
seq = Number.isNaN(n) ? null : n;
|
|
104
|
+
}
|
|
105
|
+
out.push({ pos: i, end: gt + 1, nonce, seq });
|
|
106
|
+
from = gt + 1;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
67
110
|
/** 会话命令执行与读取的公共实现 */
|
|
68
111
|
export class BaseSession {
|
|
69
112
|
sessionID;
|
|
@@ -72,21 +115,41 @@ export class BaseSession {
|
|
|
72
115
|
_interruptProbeIntervalMs;
|
|
73
116
|
_connected = false;
|
|
74
117
|
_remoteBusy = false;
|
|
75
|
-
/**
|
|
76
|
-
* 是否保持 busy:排队命令顺序执行时,中间某条完成也不清 busy,
|
|
77
|
-
* 直到序列最后一条完成(否则 web/status 会在中间命令间隙闪 busy=false)。
|
|
78
|
-
*/
|
|
79
|
-
_holdBusy = false;
|
|
80
118
|
_buffer = "";
|
|
81
119
|
_connectedAt = 0;
|
|
82
120
|
_lastActive = 0;
|
|
83
121
|
_runningStartPos = null;
|
|
84
122
|
_runningCommand = "";
|
|
85
123
|
_watchTimer = null;
|
|
86
|
-
/**
|
|
124
|
+
/** 后台兜底判定的捕获窗口起点(相对 buffer;submit 为 0,exec 转后台为 captureStart) */
|
|
125
|
+
_watchStartPos = 0;
|
|
126
|
+
/** 后台兜底判定写入 history 用的命令(独立保存,避免运行上下文清理后取不到) */
|
|
127
|
+
_watchCommand = "";
|
|
128
|
+
/** 语法错误安静窗口的一次性定时器(数据到达时重置;到点即收尾,非轮询) */
|
|
129
|
+
_syntaxTimer = null;
|
|
130
|
+
/** 语法错误首次识别时刻(0=尚未识别) */
|
|
131
|
+
_syntaxSeenAt = 0;
|
|
132
|
+
/** 语法错误静默判定起点(识别后输出最后一次变化的时刻) */
|
|
133
|
+
_syntaxQuietAt = 0;
|
|
134
|
+
/** 语法错误识别后观测到的输出长度(判断输出是否仍在变化) */
|
|
135
|
+
_syntaxLen = 0;
|
|
136
|
+
/** exec 同步等待完成的一次性 animation 定时器(到点未完成 → 兑现 "running" 转后台) */
|
|
137
|
+
_animationTimer = null;
|
|
138
|
+
/** exec 同步等待完成的 resolver(数据到达/语法静默命中时由 _settleCompletion 兑现) */
|
|
139
|
+
_completionWaiter = null;
|
|
140
|
+
/** exec 转后台后等待 busy 释放的 resolver 列表(busy 翻转为 false 时统一兑现,替代 200ms 轮询) */
|
|
141
|
+
_busyWaiters = [];
|
|
142
|
+
/** 命令序号(完成标记 <SSH_DONE:nonce:seq:code> 标识本次命令,防残留误判) */
|
|
87
143
|
_cmdSeq = 0;
|
|
88
144
|
/** 当前命令序号(_beginCapture 递增,供检测/拼接用) */
|
|
89
145
|
_runningSeq = 0;
|
|
146
|
+
/**
|
|
147
|
+
* 会话级随机短串(8 位十六进制,构造时生成):写进完成标记 `<SSH_DONE:<nonce>:<seq>:<code>>`。
|
|
148
|
+
* 远端 Windows 控制台在新连接时会重放上一会话屏幕缓冲,其中含**别的会话**的完成标记;
|
|
149
|
+
* 新会话 seq 必然从 1 起、与重放会话撞号 → 无 nonce 时会把重放标记当自己的完成/边界。
|
|
150
|
+
* 只认自己 nonce:重放标记不再冒充本会话标记。
|
|
151
|
+
*/
|
|
152
|
+
_nonce = randomUUID().replace(/-/g, "").slice(0, 8);
|
|
90
153
|
/**
|
|
91
154
|
* 命令发起前缓冲的末行(旧提示符行):语法错误收尾时 shell 会重印同形提示符,
|
|
92
155
|
* 据此把它从输出尾部剥掉(exec 的输出窗口自命令回显起,旧提示符在窗口之外,需单独记录)。
|
|
@@ -117,6 +180,8 @@ export class BaseSession {
|
|
|
117
180
|
generation = randomUUID();
|
|
118
181
|
/** 生命周期监听器(命令开始/完成事件,agent 转报 server) */
|
|
119
182
|
_lifecycle = null;
|
|
183
|
+
/** 数据到达监听器(终端有新输出写入缓冲时回调,供 agent 事件驱动推流;null 清除) */
|
|
184
|
+
_dataListener = null;
|
|
120
185
|
/**
|
|
121
186
|
* busy 状态翻转监听器(插件挂接):命令运行期间收到 true、结束写回 false,
|
|
122
187
|
* 供插件把实时 busy 同步到跨进程状态文件,避免命令执行前落盘的旧值一直"过期"。
|
|
@@ -165,8 +230,9 @@ export class BaseSession {
|
|
|
165
230
|
if (this._adapter?.hasOpenContinuation(command))
|
|
166
231
|
return { ok: false, output: "", error: tr("cmd_continuation", this._lang) };
|
|
167
232
|
this._beginCapture(command);
|
|
233
|
+
this._armTracking(0, command);
|
|
168
234
|
this._write(this._composeCommand(command));
|
|
169
|
-
this.
|
|
235
|
+
this._startFallbackWatch();
|
|
170
236
|
return { ok: true, output: "", submitted: true, command, duration: 0, ...this._extraResult };
|
|
171
237
|
}
|
|
172
238
|
/**
|
|
@@ -179,18 +245,25 @@ export class BaseSession {
|
|
|
179
245
|
if (!this._ready())
|
|
180
246
|
return { ok: false, output: "", error: tr("err_not_connected", this._lang) };
|
|
181
247
|
if (this._remoteBusy) {
|
|
182
|
-
log.info(`exec quick-fail busy ${this._logTag}
|
|
248
|
+
log.info(`exec quick-fail busy ${this._logTag} cmdLen=${command.length} (busy=${this._remoteBusy})`);
|
|
249
|
+
debug(`exec quick-fail busy ${this._logTag} cmd=${tailLines(command, 3)}`);
|
|
183
250
|
return { ok: false, output: "", error: tr("cmd_busy", this._lang) };
|
|
184
251
|
}
|
|
185
252
|
if (this._adapter?.hasOpenContinuation(command)) {
|
|
186
|
-
log.info(`exec quick-fail open continuation ${this._logTag}
|
|
253
|
+
log.info(`exec quick-fail open continuation ${this._logTag} cmdLen=${command.length}`);
|
|
254
|
+
debug(`exec quick-fail open continuation ${this._logTag} cmd=${tailLines(command, 3)}`);
|
|
187
255
|
return { ok: false, output: "", error: tr("cmd_continuation", this._lang) };
|
|
188
256
|
}
|
|
189
257
|
this._beginCapture(command);
|
|
190
258
|
const captureStart = this._buffer.length;
|
|
259
|
+
this._armTracking(captureStart, command);
|
|
260
|
+
// 先挂同步等待者再写入:写命令可能同步回灌数据(本地 PTY 快速回显),事件驱动的 _settleCompletion
|
|
261
|
+
// 需要等待者已就位;否则完成会被判成后台收尾、exec 空等
|
|
262
|
+
const completion = this._awaitCompletion();
|
|
191
263
|
this._write(this._composeCommand(command));
|
|
192
|
-
const outcome = await
|
|
193
|
-
log.info(`exec ${this._logTag} seq=${this._runningSeq}
|
|
264
|
+
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})`);
|
|
266
|
+
debug(`exec ${this._logTag} seq=${this._runningSeq} cmd=${tailLines(command, 3)}`);
|
|
194
267
|
switch (outcome.kind) {
|
|
195
268
|
case "done": {
|
|
196
269
|
// 同步完成:复用 _completeRunning 统一收尾(history/done/busy/[run] 结束日志/清上下文/停 watch),
|
|
@@ -200,29 +273,14 @@ export class BaseSession {
|
|
|
200
273
|
this._completeRunning(captureStart, endPos, outcome.exitCode ?? null, command, out, outcome.syntaxError ? "direct(syntax)" : "direct");
|
|
201
274
|
return { ok: true, output: this._truncate(await toModelText(out)), command, duration: Date.now() - startTs, ...this._extraResult };
|
|
202
275
|
}
|
|
203
|
-
case "interactive": {
|
|
204
|
-
// 交互程序(sudo 密码 / 确认提示):命令仍在等待用户输入,
|
|
205
|
-
// 保留 running 上下文与 busy 状态,转后台 watch 持续监听完成标记;
|
|
206
|
-
// 用户 send 输入后命令完成时由 watch 完整收集输出并翻转 busy(防新命令被当 exec 塞进运行中 shell)
|
|
207
|
-
const raw = this._buffer.slice(captureStart);
|
|
208
|
-
const out = this._extractOutput(raw, command);
|
|
209
|
-
this._startBackgroundWatch(captureStart, command);
|
|
210
|
-
return { ok: true, output: this._truncate(await toModelText(out)), interactive: true, command, duration: Date.now() - startTs, ...this._extraResult };
|
|
211
|
-
}
|
|
212
276
|
case "running": {
|
|
213
277
|
this._runningStartPos = 0;
|
|
214
278
|
this._setBusy(true, "cmdRunning");
|
|
215
279
|
this._cursor = this._buffer.length;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!this._remoteBusy) {
|
|
221
|
-
clearInterval(poll);
|
|
222
|
-
resolve();
|
|
223
|
-
}
|
|
224
|
-
}, 200);
|
|
225
|
-
});
|
|
280
|
+
// 转后台稀疏兜底监听(≥1s,仅覆盖断连等非数据事件);完成判定仍由数据到达事件驱动
|
|
281
|
+
this._startFallbackWatch();
|
|
282
|
+
// 等待命令收尾:事件驱动(busy 翻转为 false 时兑现),不再 200ms 轮询
|
|
283
|
+
await new Promise((resolve) => { this._busyWaiters.push(resolve); });
|
|
226
284
|
// watch 已写入 history,取最后一条记录的输出
|
|
227
285
|
const pairs = this._history.getPairs();
|
|
228
286
|
const last = pairs[pairs.length - 1];
|
|
@@ -240,7 +298,7 @@ export class BaseSession {
|
|
|
240
298
|
return { ok: false, output: "", error: tr("err_not_connected", this._lang) };
|
|
241
299
|
let out;
|
|
242
300
|
if (this._runningStartPos !== null && this._runningCommand) {
|
|
243
|
-
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._runningSeq);
|
|
301
|
+
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
|
|
244
302
|
if (marker.done) {
|
|
245
303
|
// 与后台 watch 完成路径复用同一收尾(history/done/busy/停 watch),避免 busy 卡死
|
|
246
304
|
const raw = this._completeRunning(this._runningStartPos, marker.pos, marker.exitCode, this._runningCommand, undefined, "readBuffer");
|
|
@@ -262,21 +320,18 @@ export class BaseSession {
|
|
|
262
320
|
}
|
|
263
321
|
/**
|
|
264
322
|
* 发送文本/按键到终端(交互场景:sudo 密码、确认、中断等)
|
|
265
|
-
*
|
|
266
|
-
*
|
|
323
|
+
* 转义写法统一由 decodeTerminalEscapes 解码(见 src/escapes.ts):
|
|
324
|
+
* \xHH 任意字节、\t TAB、\r/\n 回车、\\ 字面反斜杠;非法/不完整写法原样保留。
|
|
325
|
+
* 前端直接发送的真实控制字节(0x7f/0x09/ESC 序列等)不含反斜杠,逐字节原样通过,同一实现两条路径共用。
|
|
326
|
+
* @param text 要发送的文本或按键序列(转义文本或真实字节)
|
|
267
327
|
* @returns 是否发送成功
|
|
268
328
|
*/
|
|
269
329
|
send(text) {
|
|
270
330
|
if (!this._connected)
|
|
271
331
|
return { ok: false, error: tr("err_not_connected", this._lang) };
|
|
272
|
-
const payload = text
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
.replace(/\\x04/gi, "\x04")
|
|
276
|
-
.replace(/\\x1a/gi, "\x1a")
|
|
277
|
-
.replace(/\\r/g, "\r")
|
|
278
|
-
.replace(/\\n/g, "\r");
|
|
279
|
-
log.info(`send ${this._logTag} -> ${JSON.stringify(payload)} (connected=${this._connected}, busy=${this._remoteBusy})`);
|
|
332
|
+
const payload = decodeTerminalEscapes(text);
|
|
333
|
+
// 只记长度与类型,绝不打印载荷内容(用户会把密码/密钥粘进命令框或按键)
|
|
334
|
+
log.info(`send ${this._logTag} -> ${describeSend(payload)} (connected=${this._connected}, busy=${this._remoteBusy})`);
|
|
280
335
|
// 真实中断输入:标记本次命令已发过 Ctrl-C;写完中断键后进入中断周期,
|
|
281
336
|
// 周期内**立即**补发首条探针、之后按配置间隔(默认 5s)补发探针标记来收尾被中断的命令。
|
|
282
337
|
// 防抖:周期内重复 Ctrl-C 被 _startInterruptCycle 抑制(不重启周期、不额外补探针),
|
|
@@ -291,6 +346,11 @@ export class BaseSession {
|
|
|
291
346
|
return { ok: true };
|
|
292
347
|
}
|
|
293
348
|
getHistory() { return this._history; }
|
|
349
|
+
/**
|
|
350
|
+
* 本会话完成标记的随机串(只读):供诊断/测试按真实格式构造标记。
|
|
351
|
+
* @returns 8 位十六进制会话串
|
|
352
|
+
*/
|
|
353
|
+
get nonce() { return this._nonce; }
|
|
294
354
|
getRunningOutput() {
|
|
295
355
|
if (this._runningStartPos === null || !this._connected || !this._runningCommand)
|
|
296
356
|
return "";
|
|
@@ -306,6 +366,15 @@ export class BaseSession {
|
|
|
306
366
|
setLifecycle(listener) {
|
|
307
367
|
this._lifecycle = listener;
|
|
308
368
|
}
|
|
369
|
+
/**
|
|
370
|
+
* 设置数据到达监听器(每次终端有新输出写入缓冲时回调):
|
|
371
|
+
* SSH 的 `stream.on("data")` 与本地 PTY 的 data 回调最终都汇入 `_appendBuffer`,
|
|
372
|
+
* 在此统一触发,供 agent 做事件驱动的节流推流(不再依赖固定周期轮询)。
|
|
373
|
+
* @param listener 回调(null 表示清除)
|
|
374
|
+
*/
|
|
375
|
+
setDataListener(listener) {
|
|
376
|
+
this._dataListener = listener;
|
|
377
|
+
}
|
|
309
378
|
/**
|
|
310
379
|
* 设置 busy 状态翻转监听器(命令开始/结束/断连等每次 busy 翻转时回调):
|
|
311
380
|
* 供插件把最新 busy 同步到跨进程状态文件(server 拿不到 agent 状态回退读状态文件时,
|
|
@@ -315,21 +384,11 @@ export class BaseSession {
|
|
|
315
384
|
setBusyListener(listener) {
|
|
316
385
|
this._busyListener = listener;
|
|
317
386
|
}
|
|
318
|
-
/**
|
|
319
|
-
* 设置是否保持 busy(排队命令序列期间为 true):
|
|
320
|
-
* true 时中间命令完成不清 busy;置 false 时立即清 busy。
|
|
321
|
-
* @param hold 是否保持
|
|
322
|
-
*/
|
|
323
|
-
setHoldBusy(hold) {
|
|
324
|
-
this._holdBusy = hold;
|
|
325
|
-
if (!hold)
|
|
326
|
-
this._setBusy(false, "holdRelease");
|
|
327
|
-
}
|
|
328
387
|
/**
|
|
329
388
|
* 更新终端 busy 状态:仅在值翻转时记一行(会话/终端/当前 seq/翻转原因),
|
|
330
389
|
* 供事后对齐状态时间线(busy 每次翻转都能对应到具体原因,不再出现"忙了却查不出为何")。
|
|
331
390
|
* @param next 目标 busy 值
|
|
332
|
-
* @param reason 翻转原因(cmdStart / cmdRunning / cmdDone /
|
|
391
|
+
* @param reason 翻转原因(cmdStart / cmdRunning / cmdDone / 会话关闭 / 断连补 done 等)
|
|
333
392
|
*/
|
|
334
393
|
_setBusy(next, reason) {
|
|
335
394
|
if (this._remoteBusy === next)
|
|
@@ -341,6 +400,17 @@ export class BaseSession {
|
|
|
341
400
|
this._busyListener?.(next, reason);
|
|
342
401
|
}
|
|
343
402
|
catch { /* 监听器异常不打断执行 */ }
|
|
403
|
+
// busy 释放:事件驱动地兑现 exec 转后台后的等待者(替代旧的 200ms busy=false 轮询)
|
|
404
|
+
if (!next && this._busyWaiters.length > 0) {
|
|
405
|
+
const waiters = this._busyWaiters;
|
|
406
|
+
this._busyWaiters = [];
|
|
407
|
+
for (const w of waiters) {
|
|
408
|
+
try {
|
|
409
|
+
w();
|
|
410
|
+
}
|
|
411
|
+
catch { /* 等待者异常不打断状态翻转 */ }
|
|
412
|
+
}
|
|
413
|
+
}
|
|
344
414
|
}
|
|
345
415
|
/** 触发命令开始事件 */
|
|
346
416
|
_emitStart(command, ts) {
|
|
@@ -370,9 +440,16 @@ export class BaseSession {
|
|
|
370
440
|
return { data: "", done: false };
|
|
371
441
|
}
|
|
372
442
|
// 完成标记先定位(marker 唯一 seq,从命令起点搜索,免疫 echoEnd 被后序回显带偏)
|
|
373
|
-
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._runningSeq);
|
|
443
|
+
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
|
|
374
444
|
const markerEnd = marker.done ? marker.pos : this._buffer.length;
|
|
375
|
-
|
|
445
|
+
// 回显定位异常不得中断推流:按"未定位"处理(延续原有 end<=0 语义)
|
|
446
|
+
let end;
|
|
447
|
+
try {
|
|
448
|
+
end = extractOutputStart(this._buffer.slice(this._runningStartPos), this._composeEchoText(this._runningCommand), markerEnd - this._runningStartPos);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
end = -1;
|
|
452
|
+
}
|
|
376
453
|
if (end <= 0)
|
|
377
454
|
return { data: "", done: false };
|
|
378
455
|
const echoEnd = this._runningStartPos + end;
|
|
@@ -414,7 +491,7 @@ export class BaseSession {
|
|
|
414
491
|
* @returns 标记命令文本
|
|
415
492
|
*/
|
|
416
493
|
_markerCmd(seq) {
|
|
417
|
-
return this._adapter ? this._adapter.markerCmd(seq) : `printf '\\n${DONE_TAG}${seq}:%s>' $?`;
|
|
494
|
+
return this._adapter ? this._adapter.markerCmd(this._nonce, seq) : `printf '\\n${DONE_TAG}${this._nonce}:${seq}:%s>' $?`;
|
|
418
495
|
}
|
|
419
496
|
/**
|
|
420
497
|
* 组合实际写入 PTY 的命令:在规范形式(`_composeEchoText`)基础上把换行改写为行分隔符并补尾部回车。
|
|
@@ -585,19 +662,56 @@ export class BaseSession {
|
|
|
585
662
|
*/
|
|
586
663
|
_extractOutput(raw, command, syntaxMode = false) {
|
|
587
664
|
// 完成标记先定位:marker 自带唯一 seq,从窗口起点搜索即可(echoEnd 可能被连续输入的后序回显带偏)
|
|
588
|
-
const marker = detectLastDoneMarker(raw, 0, this._runningSeq);
|
|
665
|
+
const marker = detectLastDoneMarker(raw, 0, this._nonce, this._runningSeq);
|
|
589
666
|
const echoText = this._composeEchoText(command);
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
667
|
+
const echoLen = echoText.trim().length;
|
|
668
|
+
// 超长回显:extractOutputStart 会降级为"首/尾锚点"匹配(否则模式源约 265×长度,JSC 下会抛 too large)
|
|
669
|
+
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})`);
|
|
671
|
+
}
|
|
672
|
+
let end;
|
|
673
|
+
const extractStart = Date.now();
|
|
674
|
+
try {
|
|
675
|
+
end = marker.done
|
|
676
|
+
? extractOutputStart(raw, echoText, marker.pos)
|
|
677
|
+
: extractOutputStart(raw, echoText, raw.length, syntaxMode);
|
|
678
|
+
}
|
|
679
|
+
catch (e) {
|
|
680
|
+
// 回显定位异常绝不上抛(否则会中断完成收尾):记一行后退回整窗输出
|
|
681
|
+
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 回显定位失败, 退回整窗: echoLen=${echoLen} err=${e instanceof Error ? e.message : String(e)}`);
|
|
682
|
+
end = -1;
|
|
683
|
+
}
|
|
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
|
+
// 诊断:输出窗口坐标 + 是否退回整窗(startPos=回显结束偏移,endPos=窗口末尾;窗口长=endPos-startPos)
|
|
687
|
+
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(" ")}`);
|
|
693
|
+
// 诊断:窗口前 120 字符(仅 SSH_TOOL_DEBUG=1 时落盘)
|
|
694
|
+
debug(`[extract] ${this._logTag} seq=${this._runningSeq} 窗口前120: ${escapeCtl(raw.slice(0, 120))}`);
|
|
593
695
|
if (end <= 0) {
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
|
|
696
|
+
// 未定位到命令回显(脏窗口含控制台屏幕重放/超宽命令折行重绘等):
|
|
697
|
+
// **不得退回整窗**——整窗会把重放的别会话命令行/标记当输出落盘(旧 bug:outLen=14019 全是杂物)。
|
|
698
|
+
// 改为按窗口内已有标记边界裁剪:取"本命令自己的标记之前、所有非本 seq 标记之后"的边界,
|
|
699
|
+
// 先把重放段整段切掉,再剥行首残余控制码噪音;定位仍失败也只剩边界之后的少量内容。
|
|
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;
|
|
703
|
+
// 边界裁剪后"再试一次定位":此时窗口已剔除重放段,回显残片可能恢复成可匹配的连续文本
|
|
704
|
+
const retry = extractOutputStart(fallback, echoText, fallback.length, false);
|
|
705
|
+
if (retry > 0)
|
|
706
|
+
fallback = fallback.slice(retry);
|
|
707
|
+
else
|
|
708
|
+
fallback = this._stripLeadingControlNoise(fallback);
|
|
709
|
+
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 定位失败改用标记边界: boundary=${boundary} retry=${retry} 裁剪后长=${fallback.length} 原窗长=${raw.length}`);
|
|
710
|
+
// 中断命令再截到首个 ^C 回显,避免输出残留 ^C 与探针后 shell 重印的提示符(与旧版中断收尾语义一致)
|
|
597
711
|
if (this._interruptSent) {
|
|
598
|
-
const intr = detectInterrupt(
|
|
712
|
+
const intr = detectInterrupt(fallback, 0);
|
|
599
713
|
if (intr.interrupted)
|
|
600
|
-
fallback =
|
|
714
|
+
fallback = fallback.slice(0, intr.pos);
|
|
601
715
|
}
|
|
602
716
|
return this._stripProbeEchoes(stripMarkers(fallback));
|
|
603
717
|
}
|
|
@@ -625,20 +739,62 @@ export class BaseSession {
|
|
|
625
739
|
out = out.replace(this._markerCmd(this._runningSeq), "");
|
|
626
740
|
return this._stripProbeEchoes(stripMarkers(out.replace(/^[\r\n]+/, "")));
|
|
627
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* 回显定位失败时的标记边界裁剪:扫描窗口内所有完成标记,返回"本命令自己的标记之前、
|
|
744
|
+
* 所有非本 seq 标记之后"的边界偏移(无合适标记时为 0)。
|
|
745
|
+
* 用于把控制台屏幕重放(含上一会话的命令行与标记)整段切掉,避免其被当输出落盘。
|
|
746
|
+
* 判定:只按 seq 区分——seq 等于当前命令序号的标记(求值式或命令行回显式)属于本次命令,
|
|
747
|
+
* 不作边界(否则会把本次命令的回显一起切掉);其余标记(重放的别会话标记)取最后一个的结束位置。
|
|
748
|
+
* @param raw 待裁剪窗口原始字节流
|
|
749
|
+
* @param ownEnd 本命令完成标记的结束偏移(无标记时为窗口末尾)
|
|
750
|
+
* @returns 裁剪起点偏移(相对 raw)
|
|
751
|
+
*/
|
|
752
|
+
_fallbackBoundary(raw, ownEnd) {
|
|
753
|
+
let start = 0;
|
|
754
|
+
for (const h of scanDoneMarkers(raw)) {
|
|
755
|
+
if (h.pos >= ownEnd)
|
|
756
|
+
break;
|
|
757
|
+
if (h.seq === this._runningSeq)
|
|
758
|
+
continue;
|
|
759
|
+
start = Math.max(start, h.end);
|
|
760
|
+
}
|
|
761
|
+
return start;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* 剥掉文本开头的控制码/重绘噪音(光标定位、颜色、OSC 标题等非打印序列)与空白,
|
|
765
|
+
* 直到首个可见字符;用于标记边界裁剪后仍残留的重绘噪音清理。
|
|
766
|
+
* @param s 待清理文本
|
|
767
|
+
* @returns 去掉行首控制噪音后的文本
|
|
768
|
+
*/
|
|
769
|
+
_stripLeadingControlNoise(s) {
|
|
770
|
+
return s.replace(/^(?:[\s\x00-\x1f\x7f]|\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[^\x1b])+/, "");
|
|
771
|
+
}
|
|
628
772
|
/** 命令执行/提交前准备:裁剪已消费缓冲 + 丢弃上一条命令的迟到残留标记 */
|
|
629
773
|
_beginCapture(command) {
|
|
774
|
+
// 诊断用:记录进入时的缓冲/游标快照(仅用于日志,不参与裁剪决策)
|
|
775
|
+
const entryBufLen = this._buffer.length;
|
|
776
|
+
const entryCursor = this._cursor;
|
|
630
777
|
// 开始新命令:停掉上一条命令遗留的中断周期(新命令有自己的 seq 与收尾)
|
|
631
778
|
this._stopInterruptCycle("开始新命令");
|
|
632
|
-
// 用上一条命令完成标记(<SSH_DONE:seq:N>,此刻 _runningSeq 仍是上一条的)定位命令起点;
|
|
633
|
-
//
|
|
634
|
-
const marker = detectLastDoneMarker(this._buffer, 0, this._runningSeq);
|
|
779
|
+
// 用上一条命令完成标记(<SSH_DONE:nonce:seq:N>,此刻 _runningSeq 仍是上一条的)定位命令起点;
|
|
780
|
+
// 只认自己 nonce:别的会话经控制台重放混入的旧标记不会被误当本会话边界
|
|
781
|
+
const marker = detectLastDoneMarker(this._buffer, 0, this._nonce, this._runningSeq);
|
|
635
782
|
if (marker.done) {
|
|
636
783
|
this._buffer = this._buffer.slice(marker.pos);
|
|
637
|
-
this._cursor = 0;
|
|
638
784
|
}
|
|
639
|
-
|
|
785
|
+
else {
|
|
786
|
+
// 无本会话标记(首命令且基线标记尚未到达/不可用):至少按"非本 seq 标记"边界剥掉屏幕重放段
|
|
787
|
+
const replayBoundary = this._fallbackBoundary(this._buffer, this._buffer.length);
|
|
788
|
+
if (replayBoundary > 0)
|
|
789
|
+
this._buffer = this._buffer.slice(replayBoundary);
|
|
790
|
+
}
|
|
791
|
+
// fallback:再依赖 _cursor 裁剪
|
|
640
792
|
this._buffer = this._buffer.slice(this._cursor);
|
|
641
793
|
this._cursor = 0;
|
|
794
|
+
// 诊断:本次窗口起点取自"自身找到的 lastMarkerPos"(无标记时为 0,即缓冲起点);
|
|
795
|
+
// captureStart=裁剪后残留长度(exec 的实际捕获起点,与缓冲内容相关,仅供参考)
|
|
796
|
+
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}`);
|
|
642
798
|
// 记录命令发起前缓冲末行(旧提示符行):语法错误收尾时 shell 会重印同形提示符,供输出尾部剥离
|
|
643
799
|
this._promptRef = this._buffer.split("\n").pop() ?? "";
|
|
644
800
|
this._runningStartPos = 0;
|
|
@@ -649,7 +805,8 @@ export class BaseSession {
|
|
|
649
805
|
this._runningSeq = this._cmdSeq;
|
|
650
806
|
this._setBusy(true, "cmdStart");
|
|
651
807
|
this._lastActive = Date.now();
|
|
652
|
-
log.info(`[run] 开始 ${this._logTag} seq=${this._runningSeq} startPos=0
|
|
808
|
+
log.info(`[run] 开始 ${this._logTag} seq=${this._runningSeq} startPos=0 cmdLen=${command.length}`);
|
|
809
|
+
debug(`[run] 开始 ${this._logTag} seq=${this._runningSeq} cmd=${JSON.stringify(command.slice(0, 120))}`);
|
|
653
810
|
this._emitStart(command, this._runningStartTs);
|
|
654
811
|
}
|
|
655
812
|
/** 追加输出字节:维护业务缓冲 + 连续原始流 ring */
|
|
@@ -671,6 +828,35 @@ export class BaseSession {
|
|
|
671
828
|
this._rawLog = this._rawLog.slice(trimmed);
|
|
672
829
|
this._rawDiscarded += trimmed;
|
|
673
830
|
}
|
|
831
|
+
// 诊断:仅当本 chunk 含完成标记时记一行(回答"后续命令的标记为何在当前命令结束前就到")
|
|
832
|
+
if (text.includes(DONE_TAG)) {
|
|
833
|
+
const found = scanDoneMarkers(text).map((h) => h.seq ?? "?").join(",");
|
|
834
|
+
// 非内容信息保持 info(排障主力);命令/输出片段前缀(prefix)降级为 debug,避免泄漏内容
|
|
835
|
+
log.info(`[chunk] ${this._logTag} seq=${this._runningSeq} runningStart=${this._runningStartPos} chunkLen=${text.length} found=[${found}]`);
|
|
836
|
+
debug(`[chunk] ${this._logTag} seq=${this._runningSeq} prefix=${escapeCtl(text.slice(0, 60))}`);
|
|
837
|
+
}
|
|
838
|
+
// 数据已落缓冲:通知数据到达监听器(agent 据此做节流合并推流;异常不打断数据写入)
|
|
839
|
+
try {
|
|
840
|
+
this._dataListener?.();
|
|
841
|
+
}
|
|
842
|
+
catch { /* 监听器异常不打断数据写入 */ }
|
|
843
|
+
// 完成判定改为事件驱动:每个数据块落入缓冲后立即判定完成标记/语法错误(异常不打断数据写入)
|
|
844
|
+
this._onDataArrived();
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* 会话初始化完成后发送**基线标记**(seq=0):作为第一条命令的"前驱"。
|
|
848
|
+
*
|
|
849
|
+
* 背景:新连接时远端 Windows 控制台会重放上一会话的屏幕缓冲,其内容落在缓冲区开头。
|
|
850
|
+
* 旧实现首条命令 `_beginCapture` 没有"上一条标记"可依,窗口被迫从缓冲起点(=重放内容)开始,
|
|
851
|
+
* 回显定位失败便退回整窗 → 重放杂物被当输出落盘。
|
|
852
|
+
* 发一条 seq=0 的求值标记后,首条命令能像后续命令一样"裁剪到上一条标记之后",起点即干净。
|
|
853
|
+
* 须在 shell 探测完成(adapter 已定)之后调用,使标记命令语法正确;写入方式与中断探针一致(尾随提交分隔符)。
|
|
854
|
+
* @returns 无
|
|
855
|
+
*/
|
|
856
|
+
_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))}`);
|
|
859
|
+
this._write(baseline);
|
|
674
860
|
}
|
|
675
861
|
/** 探测 shell 类型:按各适配器 probeCommand 逐个探测(POSIX 系优先,pwsh/cmd 用各自独有命令),
|
|
676
862
|
* login shell 的 $0 带 - 前缀(-bash/-zsh)由对应 parseProbe 容忍 */
|
|
@@ -703,123 +889,215 @@ export class BaseSession {
|
|
|
703
889
|
return this._buffer.length > 0 ? this._buffer : null;
|
|
704
890
|
}
|
|
705
891
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
* @returns
|
|
710
|
-
* syntaxError=true 表示本次是语法/解析错误收尾(输出提取须改用首次回显匹配 + 剥尾部提示符)
|
|
892
|
+
* 挂起 exec 的同步完成等待(事件驱动,无轮询):
|
|
893
|
+
* - 数据到达(`_appendBuffer` → `_checkCompletion`)命中完成标记/语法静默时由 `_settleCompletion` 兑现;
|
|
894
|
+
* - 超过 ANIMATION_WINDOW_MS 仍未完成则由一次性 animation 定时器兑现 "running"(转后台继续追踪,不设硬超时)。
|
|
895
|
+
* @returns 完成结果(done 携带标记位置/退出码/语法错误标志;running 表示转后台)
|
|
711
896
|
*/
|
|
712
|
-
|
|
897
|
+
_awaitCompletion() {
|
|
713
898
|
return new Promise((resolve) => {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const curLen = this._buffer.length;
|
|
722
|
-
if (curLen !== lastLen)
|
|
723
|
-
lastLen = curLen;
|
|
724
|
-
// 语法/解析错误:**每轮最先判定**(优先于交互提示与"判为 running"),命中即进入静默等待再收尾。
|
|
725
|
-
// 整行命令作废(含同行拼装的完成标记也不会执行)→ 视为命令已结束。
|
|
726
|
-
// 不立即收尾:pwsh 的多行错误块由格式化器异步/批量渲染,须等输出安静一小段再收尾,
|
|
727
|
-
// 否则只截到 `ParserError:` 首行、丢掉 Line | 等后续行;安静窗口 300ms,自识别起上限 1s。
|
|
728
|
-
// 禁止"无标记超时即完成"式兜底:静默长命令(如 sleep)会被误判为已完成。
|
|
729
|
-
if (detectSyntaxError(this._adapter, this._buffer, startPos)) {
|
|
730
|
-
if (syntaxSeenAt === 0) {
|
|
731
|
-
syntaxSeenAt = now;
|
|
732
|
-
syntaxQuietAt = now;
|
|
733
|
-
syntaxLen = curLen;
|
|
734
|
-
}
|
|
735
|
-
else if (curLen !== syntaxLen) {
|
|
736
|
-
syntaxLen = curLen;
|
|
737
|
-
syntaxQuietAt = now;
|
|
738
|
-
}
|
|
739
|
-
if (now - syntaxQuietAt >= SYNTAX_QUIET_MS || now - syntaxSeenAt >= SYNTAX_MAX_WAIT_MS) {
|
|
740
|
-
clearInterval(timer);
|
|
741
|
-
// 无标记可读 → exitCode=null;markerPos 缺省 → 输出窗口取到缓冲区末尾(含整段错误)
|
|
742
|
-
resolve({ kind: "done", exitCode: null, syntaxError: true });
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
if (INTERACTIVE_RE.test(this._buffer.slice(startPos))) {
|
|
747
|
-
clearInterval(timer);
|
|
748
|
-
resolve({ kind: "interactive" });
|
|
749
|
-
return;
|
|
899
|
+
this._completionWaiter = { resolve };
|
|
900
|
+
this._animationTimer = setTimeout(() => {
|
|
901
|
+
this._animationTimer = null;
|
|
902
|
+
const w = this._completionWaiter;
|
|
903
|
+
if (w) {
|
|
904
|
+
this._completionWaiter = null;
|
|
905
|
+
w.resolve({ kind: "running" });
|
|
750
906
|
}
|
|
751
|
-
|
|
752
|
-
// marker 自带唯一 seq,从命令起点直接搜索即可(不依赖命令回显定位)。
|
|
753
|
-
// ^C 回显不作为完成依据:捕获/忽略 SIGINT 的程序(mysql/REPL)同样回显 ^C 但命令未结束,
|
|
754
|
-
// 据此判完成会把后续命令喂进运行中的程序;中断收尾改由 send() 补发的探针标记触发。
|
|
755
|
-
const marker = detectLastDoneMarker(this._buffer, startPos, this._runningSeq);
|
|
756
|
-
if (marker.done) {
|
|
757
|
-
clearInterval(timer);
|
|
758
|
-
resolve({ kind: "done", markerPos: marker.pos, exitCode: marker.exitCode });
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
// 超过动画窗口仍未出现完成信号 → 交给后台 watch 继续监听(不设硬超时)
|
|
762
|
-
if (now - startTs >= ANIMATION_WINDOW_MS) {
|
|
763
|
-
clearInterval(timer);
|
|
764
|
-
resolve({ kind: "running" });
|
|
765
|
-
return;
|
|
766
|
-
}
|
|
767
|
-
}, 50);
|
|
907
|
+
}, ANIMATION_WINDOW_MS);
|
|
768
908
|
});
|
|
769
909
|
}
|
|
770
|
-
/**
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
910
|
+
/**
|
|
911
|
+
* 数据到达事件钩子(由 `_appendBuffer` 在每个数据块落入缓冲后调用):
|
|
912
|
+
* 仅在追踪运行命令时触发完成判定,异常不打断数据写入。
|
|
913
|
+
* @returns 无
|
|
914
|
+
*/
|
|
915
|
+
_onDataArrived() {
|
|
916
|
+
if (this._runningStartPos === null || this._runningCommand === "")
|
|
917
|
+
return;
|
|
918
|
+
try {
|
|
919
|
+
this._checkCompletion(true);
|
|
920
|
+
}
|
|
921
|
+
catch (e) {
|
|
922
|
+
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 数据到达判定异常, 保持监听: err=${e instanceof Error ? e.message : String(e)}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* 完成判定(唯一入口,事件驱动):
|
|
927
|
+
* 断连优先处理;其次判当前 seq 完成标记(权威且唯一);再次判语法/解析错误(安静窗口后收尾)。
|
|
928
|
+
* ^C 回显不作为完成依据:捕获/忽略 SIGINT 的程序(mysql/REPL)同样回显 ^C 但命令未结束,
|
|
929
|
+
* 据此判完成会把后续命令喂进运行中的程序;中断收尾由 send() 补发的探针标记触发。
|
|
930
|
+
* @param fromData true=数据到达触发(推进语法静默计时);false=后台兜底 tick(不重置已挂的静默计时,避免无限顺延)
|
|
931
|
+
* @returns 无
|
|
932
|
+
*/
|
|
933
|
+
_checkCompletion(fromData) {
|
|
934
|
+
if (this._runningStartPos === null || !this._runningCommand)
|
|
935
|
+
return;
|
|
936
|
+
if (!this._connected) {
|
|
937
|
+
this._handleDisconnect();
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
try {
|
|
941
|
+
// 完成判定(权威且唯一):命令后追加的 printf 输出 <SSH_DONE:seq:code>;marker 自带唯一 seq,从命令起点搜索
|
|
942
|
+
const marker = detectLastDoneMarker(this._buffer, this._runningStartPos, this._nonce, this._runningSeq);
|
|
943
|
+
if (marker.done) {
|
|
944
|
+
this._settleCompletion({ kind: "done", markerPos: marker.pos, exitCode: marker.exitCode });
|
|
790
945
|
return;
|
|
791
946
|
}
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
947
|
+
// 语法/解析错误:整行命令作废(含同行拼装的完成标记也不会执行)→ 输出安静后按完成收尾
|
|
948
|
+
if (detectSyntaxError(this._adapter, this._buffer, this._watchStartPos)) {
|
|
949
|
+
// 兜底 tick 不重置已挂的静默计时(否则每周期顺延、永不收尾);数据到达时才推进
|
|
950
|
+
if (fromData || this._syntaxTimer === null)
|
|
951
|
+
this._armSyntaxQuiet();
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (e) {
|
|
955
|
+
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 判定异常, 保持监听: err=${e instanceof Error ? e.message : String(e)}`);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* 兑现一次完成判定:有 exec 同步等待者时只回传结果(由 exec 走统一收尾);
|
|
960
|
+
* 无等待者(submit / exec 转后台)时直接调用 `_completeRunning` 收尾。
|
|
961
|
+
* @param o 完成结果
|
|
962
|
+
* @returns 无
|
|
963
|
+
*/
|
|
964
|
+
_settleCompletion(o) {
|
|
965
|
+
const w = this._completionWaiter;
|
|
966
|
+
if (w) {
|
|
967
|
+
this._completionWaiter = null;
|
|
968
|
+
this._clearAnimationTimer();
|
|
969
|
+
w.resolve(o);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (o.kind !== "done")
|
|
973
|
+
return;
|
|
974
|
+
if (o.syntaxError) {
|
|
975
|
+
// 语法错误:输出窗口 = 回显结束 → 缓冲区末尾(含整段多行错误块),syntaxMode 提取
|
|
976
|
+
let out;
|
|
977
|
+
try {
|
|
978
|
+
out = this._extractOutput(this._buffer.slice(this._watchStartPos), this._watchCommand, true);
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
// 输出提取异常绝不影响收尾:退化为整窗原始输出,保证 history/done/busy 一定落地
|
|
982
|
+
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 语法错误输出提取异常, 使用整窗输出: err=${e instanceof Error ? e.message : String(e)}`);
|
|
983
|
+
out = this._buffer.slice(this._watchStartPos);
|
|
984
|
+
}
|
|
985
|
+
this._completeRunning(this._watchStartPos, this._buffer.length, null, this._watchCommand, out, "watch(syntax)");
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
this._completeRunning(this._watchStartPos, o.markerPos ?? this._buffer.length, o.exitCode ?? null, this._watchCommand);
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* 推进语法错误安静窗口(一次性定时器,非轮询):识别到语法错误后等输出停止增长 SYNTAX_QUIET_MS 再收尾,
|
|
992
|
+
* 确保 pwsh 异步/批量渲染的多行错误块被完整收进输出窗口;自首次识别起上限 SYNTAX_MAX_WAIT_MS。
|
|
993
|
+
* 数据到达(输出变化)时重置安静窗口;兜底 tick 不重置(见 _checkCompletion)。
|
|
994
|
+
* @returns 无
|
|
995
|
+
*/
|
|
996
|
+
_armSyntaxQuiet() {
|
|
997
|
+
const now = Date.now();
|
|
998
|
+
const len = this._buffer.length;
|
|
999
|
+
if (this._syntaxSeenAt === 0) {
|
|
1000
|
+
this._syntaxSeenAt = now;
|
|
1001
|
+
this._syntaxQuietAt = now;
|
|
1002
|
+
this._syntaxLen = len;
|
|
1003
|
+
}
|
|
1004
|
+
else if (len !== this._syntaxLen) {
|
|
1005
|
+
this._syntaxLen = len;
|
|
1006
|
+
this._syntaxQuietAt = now;
|
|
1007
|
+
}
|
|
1008
|
+
if (now - this._syntaxSeenAt >= SYNTAX_MAX_WAIT_MS) {
|
|
1009
|
+
this._clearSyntaxTimer();
|
|
1010
|
+
this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const remain = Math.max(0, SYNTAX_QUIET_MS - (now - this._syntaxQuietAt));
|
|
1014
|
+
this._clearSyntaxTimer();
|
|
1015
|
+
this._syntaxTimer = setTimeout(() => {
|
|
1016
|
+
this._syntaxTimer = null;
|
|
1017
|
+
if (this._runningStartPos === null)
|
|
798
1018
|
return;
|
|
1019
|
+
try {
|
|
1020
|
+
this._settleCompletion({ kind: "done", exitCode: null, syntaxError: true });
|
|
799
1021
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
// exitCode=null(无标记可读),输出窗口 = 回显结束 → 缓冲区末尾(含整段多行错误块,
|
|
803
|
-
// 回显取首次匹配并剥掉尾部重印的提示符行,见 _extractOutput 的 syntaxMode)
|
|
804
|
-
if (detectSyntaxError(this._adapter, this._buffer, startPos)) {
|
|
805
|
-
const now = Date.now();
|
|
806
|
-
const len = this._buffer.length;
|
|
807
|
-
if (syntaxSeenAt === 0) {
|
|
808
|
-
syntaxSeenAt = now;
|
|
809
|
-
syntaxQuietAt = now;
|
|
810
|
-
syntaxLen = len;
|
|
811
|
-
}
|
|
812
|
-
else if (len !== syntaxLen) {
|
|
813
|
-
syntaxLen = len;
|
|
814
|
-
syntaxQuietAt = now;
|
|
815
|
-
}
|
|
816
|
-
if (now - syntaxQuietAt >= SYNTAX_QUIET_MS || now - syntaxSeenAt >= SYNTAX_MAX_WAIT_MS) {
|
|
817
|
-
const out = this._extractOutput(this._buffer.slice(startPos), command, true);
|
|
818
|
-
this._completeRunning(startPos, this._buffer.length, null, command, out, "watch(syntax)");
|
|
819
|
-
return;
|
|
820
|
-
}
|
|
1022
|
+
catch (e) {
|
|
1023
|
+
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} 语法静默收尾异常: err=${e instanceof Error ? e.message : String(e)}`);
|
|
821
1024
|
}
|
|
822
|
-
},
|
|
1025
|
+
}, remain);
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* 断连收尾(由后台兜底 tick 判定 _connected=false 触发):命令视为结束(可能从未完成),
|
|
1029
|
+
* 补发 done 防 server busy 卡死;不写 history(输出窗口从未定案,无可信尾部可摘)。
|
|
1030
|
+
* @returns 无
|
|
1031
|
+
*/
|
|
1032
|
+
_handleDisconnect() {
|
|
1033
|
+
if (this._runningCommand) {
|
|
1034
|
+
this._emitDone(null, Date.now());
|
|
1035
|
+
// 断连收尾也是"结束":补记 [run] 结束;不附 tail(输出窗口从未定案,无可信尾部可摘)
|
|
1036
|
+
log.info(`[run] 结束 ${this._logTag} seq=${this._runningSeq} endPos=${this._buffer.length} exitCode=null duration=${Date.now() - this._runningStartTs}ms reason=disconnect`);
|
|
1037
|
+
}
|
|
1038
|
+
this._setBusy(false, "断连补 done");
|
|
1039
|
+
this._clearRunningContext();
|
|
1040
|
+
if (this._watchTimer) {
|
|
1041
|
+
clearInterval(this._watchTimer);
|
|
1042
|
+
this._watchTimer = null;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* 开始一次命令的后台追踪:记录兜底判定所需的捕获窗口起点/命令,并复位语法静默状态。
|
|
1047
|
+
* 不做任何轮询——完成判定由数据到达事件驱动(见 `_onDataArrived`)。
|
|
1048
|
+
* @param startPos 捕获窗口起点(相对当前 buffer;submit 为 0,exec 转后台为 captureStart)
|
|
1049
|
+
* @param command 命令(history 记录用)
|
|
1050
|
+
* @returns 无
|
|
1051
|
+
*/
|
|
1052
|
+
_armTracking(startPos, command) {
|
|
1053
|
+
this._watchStartPos = startPos;
|
|
1054
|
+
this._watchCommand = command;
|
|
1055
|
+
this._syntaxSeenAt = 0;
|
|
1056
|
+
this._syntaxQuietAt = 0;
|
|
1057
|
+
this._syntaxLen = 0;
|
|
1058
|
+
this._clearSyntaxTimer();
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* 启动稀疏兜底监听(≥1s,非高频轮询):完成判定已由数据到达事件驱动,此定时器仅覆盖
|
|
1062
|
+
* 断连(子类只置 `_connected=false`、不回调基类)与极端漏判保护;命令已不在追踪中则不启动。
|
|
1063
|
+
* @returns 无
|
|
1064
|
+
*/
|
|
1065
|
+
_startFallbackWatch() {
|
|
1066
|
+
if (this._runningStartPos === null || !this._runningCommand)
|
|
1067
|
+
return;
|
|
1068
|
+
if (this._watchTimer)
|
|
1069
|
+
return;
|
|
1070
|
+
this._watchTimer = setInterval(() => { this._fallbackTick(); }, FALLBACK_WATCH_INTERVAL_MS);
|
|
1071
|
+
}
|
|
1072
|
+
/** 后台兜底一次:断连优先处理,否则复用统一完成判定(异常不得让监听中断,否则 busy 卡死) */
|
|
1073
|
+
_fallbackTick() {
|
|
1074
|
+
try {
|
|
1075
|
+
if (this._runningStartPos === null || !this._runningCommand)
|
|
1076
|
+
return;
|
|
1077
|
+
if (!this._connected) {
|
|
1078
|
+
this._handleDisconnect();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
this._checkCompletion(false);
|
|
1082
|
+
}
|
|
1083
|
+
catch (e) {
|
|
1084
|
+
// tick 内异常:记一行后继续下一轮监听(完成判定仍只认当前 seq 标记)
|
|
1085
|
+
log.info(`[watch] ${this._logTag} seq=${this._runningSeq} tick 异常, 保持监听: err=${e instanceof Error ? e.message : String(e)}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/** 清除 exec 同步等待的 animation 一次性定时器(幂等) */
|
|
1089
|
+
_clearAnimationTimer() {
|
|
1090
|
+
if (this._animationTimer) {
|
|
1091
|
+
clearTimeout(this._animationTimer);
|
|
1092
|
+
this._animationTimer = null;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** 清除语法静默一次性定时器(幂等) */
|
|
1096
|
+
_clearSyntaxTimer() {
|
|
1097
|
+
if (this._syntaxTimer) {
|
|
1098
|
+
clearTimeout(this._syntaxTimer);
|
|
1099
|
+
this._syntaxTimer = null;
|
|
1100
|
+
}
|
|
823
1101
|
}
|
|
824
1102
|
/**
|
|
825
1103
|
* 统一命令完成收尾(供 exec 同步完成、后台 watch 与 readBuffer 复用,避免多处收尾逻辑漂移):
|
|
@@ -834,14 +1112,26 @@ export class BaseSession {
|
|
|
834
1112
|
*/
|
|
835
1113
|
_completeRunning(startPos, endPos, exitCode, command, out, reason = "watch") {
|
|
836
1114
|
const raw = this._buffer.slice(startPos, endPos);
|
|
837
|
-
|
|
1115
|
+
let output;
|
|
1116
|
+
try {
|
|
1117
|
+
output = out ?? this._extractOutput(raw, command);
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
// 输出提取异常绝不影响收尾:退化为整窗原始输出,保证 history/done/busy 一定落地
|
|
1121
|
+
log.info(`[extract] ${this._logTag} seq=${this._runningSeq} 输出提取异常, 使用整窗输出: cmdLen=${command.length} err=${e instanceof Error ? e.message : String(e)}`);
|
|
1122
|
+
output = raw;
|
|
1123
|
+
}
|
|
838
1124
|
this._history.append(command, output, this._runningStartTs, undefined, exitCode ?? undefined);
|
|
839
1125
|
this._emitDone(exitCode, Date.now(), output);
|
|
840
1126
|
this._cursor = Math.max(startPos, endPos);
|
|
841
|
-
this._setBusy(
|
|
842
|
-
//
|
|
843
|
-
|
|
844
|
-
|
|
1127
|
+
this._setBusy(false, "cmdDone"); // 命令完成即清 busy("保持 busy 到排队序列结束"的机制已移除)
|
|
1128
|
+
// 结束日志:非内容信息(长度/耗时/退出码/原因)保持 info;有界输出尾部摘要降级为 debug,避免泄漏输出内容
|
|
1129
|
+
log.info(`[run] 结束 ${this._logTag} seq=${this._runningSeq} endPos=${endPos} exitCode=${exitCode} outLen=${output.length} duration=${Date.now() - this._runningStartTs}ms reason=${reason}`);
|
|
1130
|
+
if (isDebugEnabled()) {
|
|
1131
|
+
const outTail = tailSummary(output);
|
|
1132
|
+
if (outTail)
|
|
1133
|
+
debug(`[run] 结束 ${this._logTag} seq=${this._runningSeq} tail=${outTail}`);
|
|
1134
|
+
}
|
|
845
1135
|
this._stopInterruptCycle("命令完成");
|
|
846
1136
|
this._clearRunningContext();
|
|
847
1137
|
if (this._watchTimer)
|
|
@@ -850,6 +1140,8 @@ export class BaseSession {
|
|
|
850
1140
|
}
|
|
851
1141
|
_clearRunningContext() {
|
|
852
1142
|
this._stopInterruptCycle("运行上下文清理");
|
|
1143
|
+
this._clearSyntaxTimer();
|
|
1144
|
+
this._clearAnimationTimer();
|
|
853
1145
|
this._runningStartPos = null;
|
|
854
1146
|
this._runningCommand = "";
|
|
855
1147
|
this._streamPos = null;
|