@xiaoqiong0v0/opencode-ssh-tool 0.4.0 → 0.5.0

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 (56) hide show
  1. package/README.md +22 -22
  2. package/dist/base-session.d.ts +128 -0
  3. package/dist/base-session.d.ts.map +1 -0
  4. package/dist/base-session.js +414 -0
  5. package/dist/base-session.js.map +1 -0
  6. package/dist/constants.d.ts +27 -3
  7. package/dist/constants.d.ts.map +1 -1
  8. package/dist/constants.js +28 -3
  9. package/dist/constants.js.map +1 -1
  10. package/dist/history.d.ts +14 -5
  11. package/dist/history.d.ts.map +1 -1
  12. package/dist/history.js +25 -9
  13. package/dist/history.js.map +1 -1
  14. package/dist/i18n.d.ts +67 -227
  15. package/dist/i18n.d.ts.map +1 -1
  16. package/dist/i18n.js +63 -211
  17. package/dist/i18n.js.map +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +83 -31
  20. package/dist/index.js.map +1 -1
  21. package/dist/last-match.d.ts +17 -0
  22. package/dist/last-match.d.ts.map +1 -0
  23. package/dist/last-match.js +63 -0
  24. package/dist/last-match.js.map +1 -0
  25. package/dist/local-session.d.ts +9 -91
  26. package/dist/local-session.d.ts.map +1 -1
  27. package/dist/local-session.js +46 -308
  28. package/dist/local-session.js.map +1 -1
  29. package/dist/log.d.ts +3 -0
  30. package/dist/log.d.ts.map +1 -0
  31. package/dist/log.js +5 -0
  32. package/dist/log.js.map +1 -0
  33. package/dist/server.d.ts +32 -5
  34. package/dist/server.d.ts.map +1 -1
  35. package/dist/server.js +359 -349
  36. package/dist/server.js.map +1 -1
  37. package/dist/session.d.ts +15 -108
  38. package/dist/session.d.ts.map +1 -1
  39. package/dist/session.js +69 -361
  40. package/dist/session.js.map +1 -1
  41. package/dist/shell-adapter.d.ts +20 -0
  42. package/dist/shell-adapter.d.ts.map +1 -0
  43. package/dist/shell-adapter.js +70 -0
  44. package/dist/shell-adapter.js.map +1 -0
  45. package/dist/ssh-auth.d.ts.map +1 -1
  46. package/dist/ssh-auth.js +1 -2
  47. package/dist/ssh-auth.js.map +1 -1
  48. package/dist/utils.d.ts +25 -24
  49. package/dist/utils.d.ts.map +1 -1
  50. package/dist/utils.js +277 -45
  51. package/dist/utils.js.map +1 -1
  52. package/dist/web/app.css +35 -0
  53. package/dist/web/app.js +2 -0
  54. package/dist/web/fonts/CaskaydiaCoveNerdFontMono-Regular.ttf +0 -0
  55. package/dist/web/index.html +24 -0
  56. package/package.json +6 -3
package/README.md CHANGED
@@ -4,7 +4,7 @@ opencode 插件:让 opencode 像人一样操作**长驻交互式终端会话**
4
4
 
5
5
  ## 特性
6
6
 
7
- - **长驻会话**:`ssh_cli connect` 建立连接后,`ssh_cli exec` 在同一会话执行命令,保留 cwd/环境/后台进程/sudo 缓存
7
+ - **长驻会话**:`term_cli connect` 建立连接后,`term_cli exec` 在同一会话执行命令,保留 cwd/环境/后台进程/sudo 缓存
8
8
  - **PTY 交互**:分配伪终端,可处理 sudo 密码、vi、top 等交互程序
9
9
  - **权限管控**:只读白名单直接放行、危险命令黑名单硬拒、其余走 `context.ask()` 用户审批
10
10
  - **命令完成判定**:哨兵标记 + 静默窗口 + 超时三重兜底,动画输出(进度条等)自动识别
@@ -27,17 +27,17 @@ opencode 插件:让 opencode 像人一样操作**长驻交互式终端会话**
27
27
 
28
28
  | 工具 | 入参 | 行为 |
29
29
  |---|---|---|
30
- | `ssh_cli` | `args`(完整命令行字符串) | **CLI 风格单工具**:SSH / 本地 / 容器终端会话。子命令:`connect` / `local` / `exec` / `read` / `send` / `status` / `disconnect` / `help`;用 `help` 查看完整用法 |
30
+ | `term_cli` | `args`(完整命令行字符串) | **CLI 风格单工具**:SSH / 本地 / 容器终端会话。子命令:`connect` / `local` / `exec` / `read` / `send` / `status` / `disconnect` / `help`;用 `help` 查看完整用法 |
31
31
 
32
32
  ```text
33
- ssh_cli connect root@host:22 [-n name] [-p password|file:path] # SSH 连接
34
- ssh_cli local "pwsh|docker exec -it <容器> sh" [-n name] [-c cwd] # 本地/容器终端
35
- ssh_cli exec "<command>" [-n name] [-w] # 执行命令
36
- ssh_cli read [-n name] [-s buffer|history] [-l limit] [--head] # 读输出
37
- ssh_cli send "<text>" [-n name] # 发送按键(sudo 密码等)
38
- ssh_cli status [-n name] # 状态
39
- ssh_cli disconnect [-n name] # 断开
40
- ssh_cli help # 完整用法
33
+ term_cli connect root@host:22 [-n name] [-p password|file:path] # SSH 连接
34
+ term_cli local "pwsh|docker exec -it <容器> sh" [-n name] [-c cwd] # 本地/容器终端
35
+ term_cli exec "<command>" [-n name] [-w] # 执行命令
36
+ term_cli read [-n name] [-s buffer|history] [-l limit] [--head] # 读输出
37
+ term_cli send "<text>" [-n name] # 发送按键(sudo 密码等)
38
+ term_cli status [-n name] # 状态
39
+ term_cli disconnect [-n name] # 断开
40
+ term_cli help # 完整用法
41
41
  ```
42
42
 
43
43
  > 一个 opencode 会话可创建**多个命名终端**(如 `db`、`web`、`prod`),用不同 `name` 并行维护;同名重复创建会先关闭旧的。工具只暴露一个 `args` 字符串参数,模型像敲命令行一样调用,`help` 引导其学会所有用法(单工具多命令模式,省 token)。
@@ -92,7 +92,7 @@ ssh_cli help # 完整用法
92
92
  3. **环境变量密码**:`SSH_PASS_<HOST大写>` 优先,回退 `SSH_PASSWORD`
93
93
 
94
94
  > [!WARNING] 风险警告
95
- > `ssh_cli connect` 的 `password` 参数(明文或 `file:` 路径)允许模型直接凭密码建立连接,**绕过密钥认证,一旦建立便无法像正常会话那样可靠授权与撤销**——远程主机上的命令操作难以被 opencode 权限系统有效管控。
95
+ > `term_cli connect` 的 `password` 参数(明文或 `file:` 路径)允许模型直接凭密码建立连接,**绕过密钥认证,一旦建立便无法像正常会话那样可靠授权与撤销**——远程主机上的命令操作难以被 opencode 权限系统有效管控。
96
96
  >
97
97
  > **最佳用途**:仅用于一次性**辅助安装 opencode 到远程目标**(bootstrap),或完全可信的临时环境。
98
98
  >
@@ -101,7 +101,7 @@ ssh_cli help # 完整用法
101
101
  ## 权限管控
102
102
 
103
103
  ```text
104
- ssh_cli exec(command)
104
+ term_cli exec(command)
105
105
  ├─ 命中 DENY 黑名单(rm -rf / shutdown reboot mkfs 等) → 直接拒绝
106
106
  ├─ 命中只读白名单 且 无 shell 元字符 → 直接放行
107
107
  └─ 其他 → context.ask() 用户审批(:allow / :deny / :always)
@@ -109,11 +109,11 @@ ssh_cli exec(command)
109
109
 
110
110
  - 只读白名单:`ls|cd|cat|grep|tail|head|ps|df|free|pwd|env|echo|curl|wget|git status|whoami|hostname|date|uname|uptime`
111
111
  - 命令含 `;` `&&` `|` `$()` 等 shell 元字符 → 降级走 ask(防拼接绕过)
112
- - 高危命令不进 ask 直接拒,`ssh_cli connect` 也走审批
112
+ - 高危命令不进 ask 直接拒,`term_cli connect` 也走审批
113
113
 
114
114
  ## HTTP 终端查看
115
115
 
116
- 服务默认开启(配置 `server.enabled`)。`ssh_cli status` / `ssh_cli read`(history 模式)会返回实际地址(端口 0 时自动分配,避免冲突):
116
+ 服务默认开启(配置 `server.enabled`)。`term_cli status` / `term_cli read`(history 模式)会返回实际地址(端口 0 时自动分配,避免冲突):
117
117
 
118
118
  ```
119
119
  浏览器访问 http://127.0.0.1:<port> 查看可滚动、每 2s 自动刷新的终端记录
@@ -155,16 +155,16 @@ npm publish # 只发布 dist/
155
155
 
156
156
  需真实 SSH 主机(可用 Docker 测试环境,见 `.tmp/ssh-test-docker.ps1`):
157
157
 
158
- - [ ] `ssh_cli connect` → 登录成功,cwd 为登录目录
159
- - [ ] `ssh_cli exec("ls")` 连续多次,确认永不重连(同一会话)
160
- - [ ] `ssh_cli exec("cd /var/log && pwd")` 后 `ssh_cli exec("pwd")` = `/var/log`(目录保持)
161
- - [ ] 交互场景:`ssh_cli exec("sudo ...")` 触发密码提示 → `ssh_cli read` 配合
158
+ - [ ] `term_cli connect` → 登录成功,cwd 为登录目录
159
+ - [ ] `term_cli exec("ls")` 连续多次,确认永不重连(同一会话)
160
+ - [ ] `term_cli exec("cd /var/log && pwd")` 后 `term_cli exec("pwd")` = `/var/log`(目录保持)
161
+ - [ ] 交互场景:`term_cli exec("sudo ...")` 触发密码提示 → `term_cli read` 配合
162
162
  - [ ] 白名单命令不弹审批直接执行;`rm -rf` 被拒绝
163
163
  - [ ] 白名单外命令弹 `context.ask()`,`:deny` 拒绝 / `:allow` 放行
164
- - [ ] 长命令超时:`ssh_cli exec("sleep 60")` 30s 超时返回,不挂死会话
165
- - [ ] `ssh_cli read` 取前/后 N 条历史输出(含 `includeCommand`、`source=buffer`)
166
- - [ ] `ssh_cli status` / HTTP 页面浏览器可访问
167
- - [ ] `ssh_cli disconnect` 后连接释放
164
+ - [ ] 长命令超时:`term_cli exec("sleep 60")` 30s 超时返回,不挂死会话
165
+ - [ ] `term_cli read` 取前/后 N 条历史输出(含 `includeCommand`、`source=buffer`)
166
+ - [ ] `term_cli status` / HTTP 页面浏览器可访问
167
+ - [ ] `term_cli disconnect` 后连接释放
168
168
 
169
169
  ## 文档
170
170
 
@@ -0,0 +1,128 @@
1
+ import { SessionHistory } from "./history.js";
2
+ import { type ShellAdapter } from "./shell-adapter.js";
3
+ /** 命令执行结果 */
4
+ export interface ExecResult {
5
+ ok: boolean;
6
+ output: string;
7
+ interactive?: boolean;
8
+ running?: boolean;
9
+ submitted?: boolean;
10
+ error?: string;
11
+ host?: string;
12
+ command?: string;
13
+ duration?: number;
14
+ }
15
+ /** 会话命令执行与读取的公共实现 */
16
+ export declare abstract class BaseSession {
17
+ protected readonly sessionID: string;
18
+ protected readonly name: string;
19
+ protected _connected: boolean;
20
+ protected _remoteBusy: boolean;
21
+ protected _buffer: string;
22
+ protected _connectedAt: number;
23
+ protected _lastActive: number;
24
+ protected _runningStartPos: number | null;
25
+ protected _runningCommand: string;
26
+ protected _watchTimer: ReturnType<typeof setInterval> | null;
27
+ /** 下次捕获窗口起点(相对当前 buffer):前一条命令结束后,标记/残留从此处开始 */
28
+ protected _cursor: number;
29
+ /** WS 实时流已发送位置(相对 buffer 偏移);null = 尚未定位命令回显结束点 */
30
+ protected _streamPos: number | null;
31
+ /** Shell 适配器(探测后确定) */
32
+ protected _adapter: ShellAdapter | null;
33
+ protected _closed: boolean;
34
+ /** 当前运行命令的输入时刻(history 记录展示命令发起时间用) */
35
+ private _runningStartTs;
36
+ /** 连续原始字节流(从首字节起累积,含欢迎页/探测/注入/提示符/marker);ring 裁剪 */
37
+ private _rawLog;
38
+ /** 已裁剪丢弃的字符数(前端 pos 同步用) */
39
+ private _rawDiscarded;
40
+ /** 累积原始字节总数(单调递增,前端增量游标) */
41
+ private _rawTotal;
42
+ protected readonly _history: SessionHistory;
43
+ constructor(sessionID: string, history: SessionHistory, name?: string);
44
+ /** 向传输层写入字节(子类实现:SSH 写 stream / 本地写 Bun.Terminal) */
45
+ protected abstract _write(data: string): void;
46
+ /** 关闭传输层资源(子类实现:SSH 关 client+stream / 本地关 term+proc) */
47
+ protected abstract _closeTransport(): void;
48
+ /** 结果中的类型专属字段(SSH 附加 host,本地为空) */
49
+ protected get _extraResult(): Partial<ExecResult>;
50
+ /** 忙碌提示中的状态命令名(ssh_status / local_status) */
51
+ protected abstract _statusCmd: string;
52
+ /**
53
+ * 异步提交命令:立即返回,命令后台执行,输出由后台监听收集进 history
54
+ * @param command 命令
55
+ * @returns 提交结果(立即返回,不等待命令完成)
56
+ */
57
+ submit(command: string): Promise<ExecResult>;
58
+ /**
59
+ * 在当前终端执行命令(同步等待),保留 cwd/环境
60
+ * @param command 命令
61
+ * @returns 执行结果
62
+ */
63
+ exec(command: string): Promise<ExecResult>;
64
+ /**
65
+ * 读取并清空未消费缓冲(交互/轮询场景)
66
+ * @returns 未消费输出
67
+ */
68
+ readBuffer(): Promise<{
69
+ ok: boolean;
70
+ output: string;
71
+ error?: string;
72
+ }>;
73
+ /**
74
+ * 发送文本/按键到终端(交互场景:sudo 密码、确认、中断等)
75
+ * 转义序列:\r 或 \n = 回车,\x03 = Ctrl-C,\x04 = Ctrl-D,\x1a = Ctrl-Z,\x1b = ESC
76
+ * @param text 要发送的文本或按键序列
77
+ * @returns 是否发送成功
78
+ */
79
+ send(text: string): {
80
+ ok: boolean;
81
+ error?: string;
82
+ };
83
+ getHistory(): SessionHistory;
84
+ getRunningOutput(): string;
85
+ getRunningCommand(): string;
86
+ hasRunningStream(): boolean;
87
+ /**
88
+ * 增量取运行中命令的实时输出(WS 推流用):
89
+ * 首次定位命令回显结束点,后续返回增量;done 标记出现 → 返回剩余 + done
90
+ * @returns data 本次增量;done 命令已完成
91
+ */
92
+ getRunningStream(): {
93
+ data: string;
94
+ done: boolean;
95
+ };
96
+ /** 关闭终端,清理会话态(幂等) */
97
+ close(): void;
98
+ /** 增量读取连续原始字节流:返回自 pos 之后的新增字节 */
99
+ readRawStream(pos: number): {
100
+ data: string;
101
+ pos: number;
102
+ reset?: boolean;
103
+ };
104
+ /** 是否连接且传输层就绪(子类检查自身传输对象) */
105
+ protected abstract _ready(): boolean;
106
+ /** 命令执行/提交前准备:裁剪已消费缓冲 + 丢弃上一条命令的迟到残留标记 */
107
+ private _beginCapture;
108
+ /** 追加输出字节:维护业务缓冲 + 连续原始流 ring */
109
+ protected _appendBuffer(text: string): void;
110
+ /** 探测 shell 类型:deadline 内循环发送探测命令并解析输出(shell 慢启动时也能等到就绪) */
111
+ protected _probeShell(deadline: number): Promise<ShellAdapter | null>;
112
+ /** 等待探测输出稳定:buffer 有内容且 500ms 不再变化则返回,超时返回 null */
113
+ private _waitProbeOutput;
114
+ /**
115
+ * deadline 内循环注入+等待确认,成功返回 true,超时返回 false
116
+ * 逐行发送避免因 shell 未就绪导致的整块丢失;每轮等待最大 3s
117
+ * 注入命令原样回显(不抑制),确认以 echo 唯一标识为准,避免与后续命令时序混淆
118
+ */
119
+ protected _injectAndSettle(deadline: number): Promise<boolean>;
120
+ /** 等待缓冲中出现指定文本(最多 maxMs) */
121
+ private _waitForBufferToken;
122
+ private _waitCompletion;
123
+ /** 后台监听:轮询 done 标记 → 收集输出进 history + busy=false */
124
+ private _startBackgroundWatch;
125
+ protected _clearRunningContext(): void;
126
+ private _truncate;
127
+ }
128
+ //# sourceMappingURL=base-session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-session.d.ts","sourceRoot":"","sources":["../src/base-session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,414 @@
1
+ // BaseSession:SSH(SshSession) 与本地(LocalSession) 终端的公共基类
2
+ // 统一完成标记法:注入 PROMPT_COMMAND 脚本,命令完成时输出 <SSH_DONE:退出码>
3
+ // 子类仅需实现:连接建立(connect)、数据写入(_write)、传输层清理(_closeTransport)、状态字段(getStatus)
4
+ import { ANIMATION_WINDOW_MS, MAX_OUTPUT_LEN, RAW_LOG_MAX, SETTLE_TIMEOUT_MS, } from "./constants.js";
5
+ import log from "./log.js";
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";
10
+ /** 交互触发词(sudo 密码 / 分页器 / 确认提示),含中英文 */
11
+ const INTERACTIVE_RE = /(\[sudo\] password for|password for \S+:|Password:|密码:|--More--|\(END\)|\[y\/N\]|\[Y\/n\]|yes\/no|是\/否)/i;
12
+ /** buffer 最大长度(未消费输出超限时截断头部,防内存膨胀) */
13
+ const MAX_BUFFER_LEN = 2 * 1024 * 1024;
14
+ /** 后台监听最长存活时间(防泄漏) */
15
+ const MAX_WATCH_LEN = 10 * 60_000;
16
+ /** Shell 探测等待超时 */
17
+ const PROBE_TIMEOUT_MS = 5_000;
18
+ /** 会话命令执行与读取的公共实现 */
19
+ export class BaseSession {
20
+ sessionID;
21
+ name;
22
+ _connected = false;
23
+ _remoteBusy = false;
24
+ _buffer = "";
25
+ _connectedAt = 0;
26
+ _lastActive = 0;
27
+ _runningStartPos = null;
28
+ _runningCommand = "";
29
+ _watchTimer = null;
30
+ /** 下次捕获窗口起点(相对当前 buffer):前一条命令结束后,标记/残留从此处开始 */
31
+ _cursor = 0;
32
+ /** WS 实时流已发送位置(相对 buffer 偏移);null = 尚未定位命令回显结束点 */
33
+ _streamPos = null;
34
+ /** Shell 适配器(探测后确定) */
35
+ _adapter = null;
36
+ _closed = false;
37
+ /** 当前运行命令的输入时刻(history 记录展示命令发起时间用) */
38
+ _runningStartTs = 0;
39
+ /** 连续原始字节流(从首字节起累积,含欢迎页/探测/注入/提示符/marker);ring 裁剪 */
40
+ _rawLog = "";
41
+ /** 已裁剪丢弃的字符数(前端 pos 同步用) */
42
+ _rawDiscarded = 0;
43
+ /** 累积原始字节总数(单调递增,前端增量游标) */
44
+ _rawTotal = 0;
45
+ _history;
46
+ constructor(sessionID, history, name = "default") {
47
+ this.sessionID = sessionID;
48
+ this.name = name;
49
+ this._history = history;
50
+ }
51
+ /** 结果中的类型专属字段(SSH 附加 host,本地为空) */
52
+ get _extraResult() { return {}; }
53
+ /**
54
+ * 异步提交命令:立即返回,命令后台执行,输出由后台监听收集进 history
55
+ * @param command 命令
56
+ * @returns 提交结果(立即返回,不等待命令完成)
57
+ */
58
+ async submit(command) {
59
+ if (!this._ready())
60
+ return { ok: false, output: "", error: "Not connected" };
61
+ if (this._remoteBusy)
62
+ return { ok: false, output: "", error: `Previous command still running, poll with ${this._statusCmd} first` };
63
+ this._beginCapture(command);
64
+ this._write(command + "\r");
65
+ this._startBackgroundWatch(0, command);
66
+ return { ok: true, output: "", submitted: true, command, duration: 0, ...this._extraResult };
67
+ }
68
+ /**
69
+ * 在当前终端执行命令(同步等待),保留 cwd/环境
70
+ * @param command 命令
71
+ * @returns 执行结果
72
+ */
73
+ async exec(command) {
74
+ const startTs = Date.now();
75
+ 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` };
79
+ this._beginCapture(command);
80
+ const captureStart = this._buffer.length;
81
+ this._write(command + "\r");
82
+ const outcome = await this._waitCompletion(0, startTs);
83
+ switch (outcome.kind) {
84
+ case "done": {
85
+ this._remoteBusy = false;
86
+ const raw = this._buffer.slice(captureStart, outcome.markerPos ?? this._buffer.length);
87
+ this._cursor = Math.max(0, outcome.markerPos ?? this._buffer.length);
88
+ this._history.append(command, extractOutput(raw, command), this._runningStartTs, Date.now());
89
+ this._clearRunningContext();
90
+ return { ok: true, output: this._truncate(toModelText(extractOutput(raw, command))), command, duration: Date.now() - startTs, ...this._extraResult };
91
+ }
92
+ case "interactive": {
93
+ this._remoteBusy = false;
94
+ const raw = this._buffer.slice(captureStart);
95
+ this._cursor = this._buffer.length;
96
+ this._history.append(command, raw, 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 };
99
+ }
100
+ case "running": {
101
+ this._runningStartPos = 0;
102
+ this._remoteBusy = true;
103
+ this._cursor = this._buffer.length;
104
+ this._startBackgroundWatch(captureStart, command);
105
+ // 等待后台 watch 完成(不设硬超时,模型可通过 Ctrl-C 中断)
106
+ await new Promise((resolve) => {
107
+ const poll = setInterval(() => {
108
+ if (!this._remoteBusy) {
109
+ clearInterval(poll);
110
+ resolve();
111
+ }
112
+ }, 200);
113
+ });
114
+ // watch 已写入 history,取最后一条记录的输出
115
+ const pairs = this._history.getPairs();
116
+ const last = pairs[pairs.length - 1];
117
+ const output = last ? this._truncate(toModelText(stripMarkers(this._history.readOutput(last)))) : "";
118
+ return { ok: true, output, command, duration: Date.now() - startTs, ...this._extraResult };
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * 读取并清空未消费缓冲(交互/轮询场景)
124
+ * @returns 未消费输出
125
+ */
126
+ async readBuffer() {
127
+ if (!this._connected)
128
+ return { ok: false, output: "", error: "Not connected" };
129
+ let out;
130
+ 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 };
135
+ if (marker.done) {
136
+ const raw = this._buffer.slice(this._runningStartPos, marker.pos);
137
+ this._buffer = this._buffer.slice(marker.pos);
138
+ this._cursor = 0;
139
+ this._clearRunningContext();
140
+ out = raw;
141
+ }
142
+ else {
143
+ out = this._buffer.slice(this._runningStartPos);
144
+ }
145
+ }
146
+ else {
147
+ out = this._buffer;
148
+ this._buffer = "";
149
+ this._cursor = 0;
150
+ }
151
+ return { ok: true, output: this._truncate(toModelText(out)) };
152
+ }
153
+ /**
154
+ * 发送文本/按键到终端(交互场景:sudo 密码、确认、中断等)
155
+ * 转义序列:\r 或 \n = 回车,\x03 = Ctrl-C,\x04 = Ctrl-D,\x1a = Ctrl-Z,\x1b = ESC
156
+ * @param text 要发送的文本或按键序列
157
+ * @returns 是否发送成功
158
+ */
159
+ send(text) {
160
+ if (!this._connected)
161
+ return { ok: false, error: "Not connected" };
162
+ const payload = text
163
+ .replace(/\\x1b/gi, "\x1b")
164
+ .replace(/\\x03/gi, "\x03")
165
+ .replace(/\\x04/gi, "\x04")
166
+ .replace(/\\x1a/gi, "\x1a")
167
+ .replace(/\\r/g, "\r")
168
+ .replace(/\\n/g, "\r");
169
+ this._write(payload);
170
+ this._lastActive = Date.now();
171
+ return { ok: true };
172
+ }
173
+ getHistory() { return this._history; }
174
+ getRunningOutput() {
175
+ if (this._runningStartPos === null || !this._connected || !this._runningCommand)
176
+ return "";
177
+ return this._buffer.slice(this._runningStartPos);
178
+ }
179
+ getRunningCommand() {
180
+ return this._runningStartPos !== null && this._connected ? this._runningCommand : "";
181
+ }
182
+ hasRunningStream() {
183
+ return this._runningStartPos !== null && this._connected && this._runningCommand !== "";
184
+ }
185
+ /**
186
+ * 增量取运行中命令的实时输出(WS 推流用):
187
+ * 首次定位命令回显结束点,后续返回增量;done 标记出现 → 返回剩余 + done
188
+ * @returns data 本次增量;done 命令已完成
189
+ */
190
+ getRunningStream() {
191
+ if (this._runningStartPos === null || !this._connected || !this._runningCommand) {
192
+ this._streamPos = null;
193
+ return { data: "", done: false };
194
+ }
195
+ const end = extractOutputStart(this._buffer.slice(this._runningStartPos), this._runningCommand);
196
+ if (end <= 0)
197
+ return { data: "", done: false };
198
+ const echoEnd = this._runningStartPos + end;
199
+ const marker = detectLastDoneMarker(this._buffer, echoEnd);
200
+ const windowEnd = marker.done ? marker.pos : this._buffer.length;
201
+ if (this._streamPos === null) {
202
+ this._streamPos = echoEnd;
203
+ }
204
+ const raw = this._buffer.slice(this._streamPos, windowEnd);
205
+ this._streamPos = windowEnd;
206
+ return { data: stripMarkers(raw), done: marker.done };
207
+ }
208
+ /** 关闭终端,清理会话态(幂等) */
209
+ close() {
210
+ if (this._closed)
211
+ return;
212
+ this._closed = true;
213
+ if (this._watchTimer)
214
+ clearInterval(this._watchTimer);
215
+ this._closeTransport();
216
+ this._connected = false;
217
+ this._remoteBusy = false;
218
+ this._history.dispose();
219
+ }
220
+ /** 增量读取连续原始字节流:返回自 pos 之后的新增字节 */
221
+ readRawStream(pos) {
222
+ if (pos < this._rawDiscarded)
223
+ return { data: this._rawLog, pos: this._rawTotal, reset: true };
224
+ const from = pos - this._rawDiscarded;
225
+ return { data: this._rawLog.slice(from), pos: this._rawTotal };
226
+ }
227
+ /** 命令执行/提交前准备:裁剪已消费缓冲 + 丢弃上一条命令的迟到残留标记 */
228
+ _beginCapture(command) {
229
+ // 用上一条命令完成标记(最后一个合法 <SSH_DONE:N>)定位命令起点;
230
+ // 备屏重放等场景下 buffer 可能含旧标记,裁剪到最后一个标记之后即可
231
+ const marker = detectLastDoneMarker(this._buffer, 0);
232
+ if (marker.done) {
233
+ this._buffer = this._buffer.slice(marker.pos);
234
+ this._cursor = 0;
235
+ }
236
+ // fallback:无标记时依赖 _cursor 裁剪
237
+ this._buffer = this._buffer.slice(this._cursor);
238
+ this._cursor = 0;
239
+ this._runningStartPos = 0;
240
+ this._runningCommand = command;
241
+ this._runningStartTs = Date.now();
242
+ this._remoteBusy = true;
243
+ this._lastActive = Date.now();
244
+ }
245
+ /** 追加输出字节:维护业务缓冲 + 连续原始流 ring */
246
+ _appendBuffer(text) {
247
+ this._buffer += text;
248
+ if (this._buffer.length > MAX_BUFFER_LEN) {
249
+ const trimmed = this._buffer.length - MAX_BUFFER_LEN;
250
+ this._buffer = this._buffer.slice(trimmed);
251
+ if (this._runningStartPos !== null)
252
+ this._runningStartPos = Math.max(0, this._runningStartPos - trimmed);
253
+ this._cursor = Math.max(0, this._cursor - trimmed);
254
+ if (this._streamPos !== null)
255
+ this._streamPos = Math.max(0, this._streamPos - trimmed);
256
+ }
257
+ this._rawLog += text;
258
+ this._rawTotal += text.length;
259
+ if (this._rawLog.length > RAW_LOG_MAX) {
260
+ const trimmed = this._rawLog.length - RAW_LOG_MAX;
261
+ this._rawLog = this._rawLog.slice(trimmed);
262
+ this._rawDiscarded += trimmed;
263
+ }
264
+ }
265
+ /** 探测 shell 类型:deadline 内循环发送探测命令并解析输出(shell 慢启动时也能等到就绪) */
266
+ async _probeShell(deadline) {
267
+ 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);
272
+ this._buffer = "";
273
+ return adapter;
274
+ }
275
+ this._buffer = "";
276
+ }
277
+ return null;
278
+ }
279
+ /** 等待探测输出稳定:buffer 有内容且 500ms 不再变化则返回,超时返回 null */
280
+ async _waitProbeOutput(maxMs) {
281
+ const start = Date.now();
282
+ let lastLen = this._buffer.length;
283
+ let lastChange = Date.now();
284
+ while (Date.now() - start < maxMs) {
285
+ if (this._buffer.length > 0 && Date.now() - lastChange >= 500)
286
+ return this._buffer;
287
+ if (this._buffer.length !== lastLen) {
288
+ lastLen = this._buffer.length;
289
+ lastChange = Date.now();
290
+ }
291
+ await new Promise((r) => setTimeout(r, 50));
292
+ }
293
+ return this._buffer.length > 0 ? this._buffer : null;
294
+ }
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
+ _waitCompletion(startPos, startTs) {
331
+ return new Promise((resolve) => {
332
+ let lastLen = this._buffer.length;
333
+ let echoEnd = 0;
334
+ const timer = setInterval(() => {
335
+ if (INTERACTIVE_RE.test(this._buffer.slice(startPos))) {
336
+ clearInterval(timer);
337
+ resolve({ kind: "interactive" });
338
+ return;
339
+ }
340
+ const now = Date.now();
341
+ const curLen = this._buffer.length;
342
+ if (curLen !== lastLen)
343
+ 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;
348
+ }
349
+ // 完成标记(精确权威):命令结束时 shell 重绘提示符前必输出 <SSH_DONE>,
350
+ // 下载/编译等静默期不会误判完成,中断(Ctrl-C 回到提示符)同样会输出标记。
351
+ // 必须等命令回显出现后才检测:回显前的标记只可能是上一条命令的迟到残留。
352
+ if (echoEnd > 0) {
353
+ const marker = detectLastDoneMarker(this._buffer, echoEnd);
354
+ if (marker.done) {
355
+ clearInterval(timer);
356
+ resolve({ kind: "done", markerPos: marker.pos });
357
+ return;
358
+ }
359
+ }
360
+ // 超过动画窗口仍未出现完成标记 → 交给后台 watch 继续监听(不设硬超时)
361
+ if (now - startTs >= ANIMATION_WINDOW_MS) {
362
+ clearInterval(timer);
363
+ resolve({ kind: "running" });
364
+ return;
365
+ }
366
+ }, 50);
367
+ });
368
+ }
369
+ /** 后台监听:轮询 done 标记 → 收集输出进 history + busy=false */
370
+ _startBackgroundWatch(startPos, command) {
371
+ if (this._watchTimer)
372
+ clearInterval(this._watchTimer);
373
+ const born = Date.now();
374
+ let echoEnd = 0;
375
+ this._watchTimer = setInterval(() => {
376
+ if (!this._connected || Date.now() - born > MAX_WATCH_LEN) {
377
+ this._remoteBusy = false;
378
+ this._clearRunningContext();
379
+ if (this._watchTimer)
380
+ clearInterval(this._watchTimer);
381
+ return;
382
+ }
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;
396
+ this._clearRunningContext();
397
+ if (this._watchTimer)
398
+ clearInterval(this._watchTimer);
399
+ }
400
+ }, 200);
401
+ }
402
+ _clearRunningContext() {
403
+ this._runningStartPos = null;
404
+ this._runningCommand = "";
405
+ this._streamPos = null;
406
+ this._runningStartTs = 0;
407
+ }
408
+ _truncate(s) {
409
+ if (s.length <= MAX_OUTPUT_LEN)
410
+ return s;
411
+ return `${s.slice(0, MAX_OUTPUT_LEN)}\n... [output truncated, ${s.length} chars total]`;
412
+ }
413
+ }
414
+ //# sourceMappingURL=base-session.js.map