@xiaoqiong0v0/opencode-ssh-tool 0.5.1 → 0.6.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.
- package/README.md +27 -21
- package/dist/agent.d.ts +52 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +250 -0
- package/dist/agent.js.map +1 -0
- package/dist/base-session.d.ts +79 -13
- package/dist/base-session.d.ts.map +1 -1
- package/dist/base-session.js +246 -100
- package/dist/base-session.js.map +1 -1
- package/dist/config.d.ts +6 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -7
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +14 -15
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +14 -15
- package/dist/constants.js.map +1 -1
- package/dist/i18n.d.ts +24 -14
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +37 -12
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +90 -73
- package/dist/index.js.map +1 -1
- package/dist/last-match.d.ts +11 -0
- package/dist/last-match.d.ts.map +1 -1
- package/dist/last-match.js +24 -0
- package/dist/last-match.js.map +1 -1
- package/dist/local-session.d.ts +0 -1
- package/dist/local-session.d.ts.map +1 -1
- package/dist/local-session.js +12 -8
- package/dist/local-session.js.map +1 -1
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +61 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-manager.d.ts +9 -9
- package/dist/server-manager.d.ts.map +1 -1
- package/dist/server-manager.js +109 -20
- package/dist/server-manager.js.map +1 -1
- package/dist/server.d.ts +3 -28
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +417 -275
- package/dist/server.js.map +1 -1
- package/dist/session.d.ts +0 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +2 -9
- package/dist/session.js.map +1 -1
- package/dist/shell-adapter.d.ts +40 -2
- package/dist/shell-adapter.d.ts.map +1 -1
- package/dist/shell-adapter.js +252 -24
- package/dist/shell-adapter.js.map +1 -1
- package/dist/utils.d.ts +3 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +29 -10
- package/dist/utils.js.map +1 -1
- package/dist/web/app.css +12 -7
- package/dist/web/app.js +11 -2
- package/dist/web/index.html +6 -3
- package/dist/web/xterm.css +218 -0
- package/package.json +3 -2
package/dist/server.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// 独立 HTTP 服务:聚合多进程会话 + WebSocket 实时流
|
|
2
|
+
// 协议(统一事件流):agent 上报 cmdStart/out/cmdDone/raw,server 按订阅者 _mode 派发 diff/raw;
|
|
3
|
+
// snapshot 仅订阅/模式切换时作为基线发送,日常由 diff 增量累积渲染。
|
|
3
4
|
import { createServer } from "node:http";
|
|
4
|
-
import { readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { readFileSync, readdirSync, existsSync, rmSync, statSync } from "node:fs";
|
|
5
6
|
import { join, dirname, extname, normalize } from "node:path";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { WebSocketServer, WebSocket } from "ws";
|
|
8
9
|
import { listAllSessions, removeSessionState } from "./session-store.js";
|
|
9
10
|
import { tr } from "./i18n.js";
|
|
10
|
-
import { PTY_COLS } from "./constants.js";
|
|
11
|
+
import { PTY_COLS, PTY_ROWS } from "./constants.js";
|
|
11
12
|
import log from "./log.js";
|
|
12
|
-
/** 前端模板中需要替换的 i18n 占位键 */
|
|
13
13
|
const PAGE_KEYS = [
|
|
14
14
|
"web_title",
|
|
15
15
|
"web_loading",
|
|
@@ -20,40 +20,28 @@ const PAGE_KEYS = [
|
|
|
20
20
|
"web_delete_terminal",
|
|
21
21
|
"web_cmd_placeholder",
|
|
22
22
|
"web_send_ctrlc",
|
|
23
|
+
"web_raw",
|
|
23
24
|
];
|
|
24
|
-
/** 前端 JS 内 I18N 对象键 → i18n key */
|
|
25
25
|
const JS_I18N_KEYS = {
|
|
26
|
-
run: "web_running",
|
|
27
26
|
commands: "web_commands",
|
|
28
|
-
autoRefresh: "web_auto_refresh",
|
|
29
27
|
sessionGone: "web_session_gone",
|
|
30
28
|
noSession: "web_no_session",
|
|
31
|
-
loadFailed: "web_load_failed",
|
|
32
29
|
local: "web_local",
|
|
33
30
|
terminals: "web_terminals",
|
|
34
31
|
cmdPlaceholder: "web_cmd_placeholder",
|
|
35
32
|
sendCtrlC: "web_send_ctrlc",
|
|
36
33
|
};
|
|
37
|
-
/** WS 运行增量轮询间隔 */
|
|
38
|
-
const STREAM_TICK_MS = 100;
|
|
39
|
-
/** runEnd 后等待快照稳定(后台哨兵监听写入 history)的缓冲时间 */
|
|
40
|
-
const SNAP_STABLE_MS = 400;
|
|
41
|
-
/** 会话列表推送间隔(服务端主动推送,非浏览器轮询) */
|
|
42
34
|
const STATUS_PUSH_MS = 2000;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
* @param port 端口,0 或未指定 → 系统随机分配(避免冲突)
|
|
46
|
-
* @param getSessions 获取扁平会话条目列表的函数(供本进程页面展示与实时流)
|
|
47
|
-
* @param dir 插件缓存根目录(跨进程状态文件/历史文件所在处)
|
|
48
|
-
* @param lang Web 界面语言(默认 en)
|
|
49
|
-
* @returns 服务句柄(含实际端口与 URL)
|
|
50
|
-
*/
|
|
51
|
-
export function startServer(port, getSessions, dir, lang = "en") {
|
|
35
|
+
const sessionKey = (sid, name) => `${sid}:${name}`;
|
|
36
|
+
export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs = 100) {
|
|
52
37
|
let actualPort = port;
|
|
53
|
-
// 前端产物目录:dist/web(相对本模块 dist/server.js 的上一级)
|
|
54
38
|
const webDir = join(dirname(fileURLToPath(import.meta.url)), "web");
|
|
55
|
-
// 模板只读一次(dist/web/index.html),每次请求做 i18n 占位替换
|
|
56
39
|
const pageTemplate = readTemplate(webDir);
|
|
40
|
+
const agents = new Map();
|
|
41
|
+
const agentBySession = new Map();
|
|
42
|
+
const streamBuf = new Map();
|
|
43
|
+
const rawBuf = new Map();
|
|
44
|
+
const cmdCount = new Map();
|
|
57
45
|
const server = createServer((req, res) => {
|
|
58
46
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
59
47
|
const path = url.pathname;
|
|
@@ -68,7 +56,6 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
68
56
|
res.end(html);
|
|
69
57
|
return;
|
|
70
58
|
}
|
|
71
|
-
// 静态资源:/web/* → dist/web/*(JS/CSS/字体)
|
|
72
59
|
if (path.startsWith("/web/")) {
|
|
73
60
|
serveStatic(res, webDir, decodeURIComponent(path.slice("/web/".length)));
|
|
74
61
|
return;
|
|
@@ -76,7 +63,6 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
76
63
|
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
77
64
|
res.end("Not Found");
|
|
78
65
|
});
|
|
79
|
-
// ===== WebSocket:会话列表 + transcript 快照 + 运行中增量 =====
|
|
80
66
|
const wss = new WebSocketServer({ noServer: true });
|
|
81
67
|
server.on("upgrade", (req, socket, head) => {
|
|
82
68
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
@@ -84,35 +70,77 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
84
70
|
socket.destroy();
|
|
85
71
|
return;
|
|
86
72
|
}
|
|
87
|
-
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
88
|
-
wss.emit("connection", ws, req);
|
|
89
|
-
});
|
|
73
|
+
wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); });
|
|
90
74
|
});
|
|
91
|
-
// 发送 JSON 消息给客户端(连接未开则忽略)
|
|
92
75
|
const send = (ws, msg) => {
|
|
93
76
|
if (ws.readyState === WebSocket.OPEN)
|
|
94
77
|
ws.send(JSON.stringify(msg));
|
|
95
78
|
};
|
|
96
|
-
/**
|
|
79
|
+
/** 订阅指定会话的 transcript 客户端深拷贝 I/O 深处 */
|
|
80
|
+
const subscribersOf = (sid, name) => {
|
|
81
|
+
return [...wss.clients].filter((c) => c.readyState === WebSocket.OPEN && c._sub?.sid === sid && c._sub?.name === name);
|
|
82
|
+
};
|
|
97
83
|
const buildSessions = () => {
|
|
98
84
|
const states = listAllSessions(dir);
|
|
99
|
-
//
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
85
|
+
// 补:state 文件可能被清理(重启/断线),但 history 目录仍在——扫描发现离线历史终端
|
|
86
|
+
const histTerms = [];
|
|
87
|
+
for (const sidDir of readdirSync(dir)) {
|
|
88
|
+
const sidPath = join(dir, sidDir);
|
|
89
|
+
let st = null;
|
|
90
|
+
try {
|
|
91
|
+
st = statSync(sidPath);
|
|
92
|
+
}
|
|
93
|
+
catch { /* 不存在 */ }
|
|
94
|
+
if (!st?.isDirectory())
|
|
95
|
+
continue; // 跳过 server.json 等根级文件
|
|
96
|
+
let subs;
|
|
97
|
+
try {
|
|
98
|
+
subs = readdirSync(sidPath);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!subs.length)
|
|
104
|
+
continue;
|
|
105
|
+
for (const sub of subs) {
|
|
106
|
+
try {
|
|
107
|
+
const subPath = join(sidPath, sub);
|
|
108
|
+
const subSt = statSync(subPath);
|
|
109
|
+
if (subSt.isDirectory() && readdirSync(subPath).some((f) => f.endsWith(".json"))) {
|
|
110
|
+
if (sub.startsWith("local-"))
|
|
111
|
+
histTerms.push({ sessionID: sidDir, name: sub.slice(6), kind: "local" });
|
|
112
|
+
else
|
|
113
|
+
histTerms.push({ sessionID: sidDir, name: sub, kind: "ssh" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch { /* 跳过 */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const liveKey = new Set(agentBySession.keys());
|
|
121
|
+
const liveBusy = new Map();
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
// 兜底:streamBuf 中 agent 连接已消失的 key 强制清 busy(agent 崩溃/退出防卡死)。
|
|
124
|
+
// 注意不能用"无 out 超时"判定——交互命令(sudo/read 等输入)等待期间无输出,
|
|
125
|
+
// 但 agent 仍在,命令未完成,busy 必须保持 true(完成由 agent 生命周期钩子兜底,含 watch 超时发 done)
|
|
126
|
+
for (const [key, b] of streamBuf) {
|
|
127
|
+
if (b && !b.done && !agentBySession.has(key)) {
|
|
128
|
+
b.done = true;
|
|
129
|
+
b.ts = now;
|
|
130
|
+
b.endTs = now;
|
|
131
|
+
finalizeCommand(key);
|
|
132
|
+
}
|
|
133
|
+
liveBusy.set(key, !!b && !b.done);
|
|
106
134
|
}
|
|
107
135
|
for (const s of states) {
|
|
108
|
-
if (s.connected && !liveKey.has(
|
|
136
|
+
if (s.connected && !liveKey.has(sessionKey(s.sessionID, s.name))) {
|
|
109
137
|
s.connected = false;
|
|
110
138
|
}
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
s.busy =
|
|
115
|
-
|
|
139
|
+
const lk = sessionKey(s.sessionID, s.name);
|
|
140
|
+
const busy = liveBusy.get(lk);
|
|
141
|
+
if (busy !== undefined)
|
|
142
|
+
s.busy = busy;
|
|
143
|
+
seen.add(sessionKey(s.sessionID, s.name));
|
|
116
144
|
}
|
|
117
145
|
const bySession = new Map();
|
|
118
146
|
for (const s of states) {
|
|
@@ -120,114 +148,81 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
120
148
|
bySession.set(s.sessionID, { title: s.title, directory: s.directory, terminals: [] });
|
|
121
149
|
bySession.get(s.sessionID).terminals.push({ name: s.name, kind: s.kind, host: s.host, user: s.user, port: s.port, program: s.program, connected: s.connected, busy: s.busy, pending: s.pending });
|
|
122
150
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
151
|
+
// 补:history 目录里存在但 state 缺失的历史终端(离线展示)
|
|
152
|
+
for (const h of histTerms) {
|
|
153
|
+
const k = sessionKey(h.sessionID, h.name);
|
|
154
|
+
if (seen.has(k))
|
|
155
|
+
continue;
|
|
156
|
+
seen.add(k);
|
|
157
|
+
if (!bySession.has(h.sessionID))
|
|
158
|
+
bySession.set(h.sessionID, { terminals: [] });
|
|
159
|
+
bySession.get(h.sessionID).terminals.push({ name: h.name, kind: h.kind, connected: false, busy: false, pending: 0 });
|
|
160
|
+
}
|
|
161
|
+
const sessions = [...bySession.entries()].map(([sessionID, v]) => ({ sessionID, title: v.title, directory: v.directory, terminals: v.terminals }));
|
|
129
162
|
return { port: actualPort, sessions };
|
|
130
163
|
};
|
|
131
|
-
/**
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const runningCmd = entry.session.getRunningCommand();
|
|
147
|
-
if (runningCmd)
|
|
148
|
-
pairs.push({ type: "cmd", ts: Date.now(), text: runningCmd });
|
|
149
|
-
const running = entry.session.getRunningOutput();
|
|
150
|
-
if (running)
|
|
151
|
-
pairs.push({ type: "run", text: running });
|
|
152
|
-
}
|
|
153
|
-
return { pairs };
|
|
164
|
+
/** 组装全量历史基线(snapshot):文件历史 + 运行中命令转成 cmd+run 对 */
|
|
165
|
+
const resolveHistName = (sid, name) => {
|
|
166
|
+
// 不依赖 state 文件(断线/重启后 state 可能已清理):直接探测 local 前缀目录
|
|
167
|
+
const base = join(dir, sid);
|
|
168
|
+
// local 终端历史存 `local-${name}`,ssh 存 `${name}`;两者目录名称不能并存取其一
|
|
169
|
+
const localDir = join(base, `local-${name}`);
|
|
170
|
+
const plainDir = join(base, name);
|
|
171
|
+
let hasLocal = false;
|
|
172
|
+
let hasPlain = false;
|
|
173
|
+
try {
|
|
174
|
+
hasLocal = readdirSync(localDir).some((f) => f.endsWith(".json"));
|
|
175
|
+
}
|
|
176
|
+
catch { /* 目录不存在 */ }
|
|
177
|
+
try {
|
|
178
|
+
hasPlain = readdirSync(plainDir).some((f) => f.endsWith(".json"));
|
|
154
179
|
}
|
|
155
|
-
|
|
180
|
+
catch { /* 目录不存在 */ }
|
|
181
|
+
if (hasLocal && !hasPlain)
|
|
182
|
+
return `local-${name}`;
|
|
183
|
+
if (hasPlain && !hasLocal)
|
|
184
|
+
return name;
|
|
185
|
+
// 两者都不存在 → 沿用 state 判定(在线 local 会话刚建历史未落盘时)
|
|
156
186
|
const state = listAllSessions(dir).find((s) => s.sessionID === sid && s.name === name);
|
|
157
|
-
|
|
187
|
+
return state?.kind === "local" ? `local-${name}` : name;
|
|
188
|
+
};
|
|
189
|
+
const buildSnapshot = (sid, name) => {
|
|
190
|
+
const state = listAllSessions(dir).find((s) => s.sessionID === sid && s.name === name);
|
|
191
|
+
const histName = resolveHistName(sid, name);
|
|
158
192
|
const pairs = readHistoryFromFile(dir, sid, histName) ?? [];
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
193
|
+
const sKey = sessionKey(sid, name);
|
|
194
|
+
const buf = streamBuf.get(sKey);
|
|
195
|
+
if (buf && !buf.done && buf.command) {
|
|
196
|
+
pairs.push({ type: "cmd", ts: buf.ts, text: buf.command });
|
|
197
|
+
if (buf.data)
|
|
198
|
+
pairs.push({ type: "run", text: buf.data });
|
|
162
199
|
}
|
|
200
|
+
if (pairs.length === 0 && !state)
|
|
201
|
+
return { pairs: [], notFound: true };
|
|
163
202
|
return { pairs };
|
|
164
203
|
};
|
|
165
|
-
/**
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
continue;
|
|
176
|
-
const sub = client._sub;
|
|
177
|
-
if (!sub)
|
|
178
|
-
continue;
|
|
179
|
-
const entry = getSessions().find((e) => e.sessionID === sub.sid && e.name === sub.name);
|
|
180
|
-
const session = entry?.session;
|
|
181
|
-
if (!session) {
|
|
182
|
-
// 跨进程会话(无本进程句柄):仅在有变化时推文件级快照(低频,退化无实时流)
|
|
183
|
-
const snap = buildSnapshot(sub.sid, sub.name);
|
|
184
|
-
const key = JSON.stringify(snap);
|
|
185
|
-
if (key !== client._lastKey) {
|
|
186
|
-
client._lastKey = key;
|
|
187
|
-
send(client, { type: "snapshot", sessionID: sub.sid, name: sub.name, ...snap });
|
|
188
|
-
}
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
// 命令开始运行:检测从空闲→运行切换,立即推送命令文本(前端先渲染命令行,动画紧跟其后)
|
|
192
|
-
const runActive = session.hasRunningStream();
|
|
193
|
-
if (runActive && !client._wasRunning) {
|
|
194
|
-
const cmd = session.getRunningCommand();
|
|
195
|
-
if (cmd)
|
|
196
|
-
send(client, { type: "cmdStart", command: cmd });
|
|
197
|
-
}
|
|
198
|
-
client._wasRunning = runActive;
|
|
199
|
-
// 运行增量推送(cmdStart 之后,保证前端先建命令行再建 run 块)
|
|
200
|
-
const st = session.getRunningStream();
|
|
201
|
-
if (st.done) {
|
|
202
|
-
// 仅首次转 done 时发 runEnd,避免后续 tick 重复发送重置 snapBase
|
|
203
|
-
if (!client._forceSnap) {
|
|
204
|
-
send(client, { type: "runEnd" });
|
|
205
|
-
client._forceSnap = true;
|
|
206
|
-
client._snapBase = Date.now();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
else if (st.data) {
|
|
210
|
-
send(client, { type: "run", data: st.data });
|
|
211
|
-
}
|
|
212
|
-
// 快照推送:runEnd 后等后台写入稳定,或空闲期历史变化(exec 同步完成)
|
|
213
|
-
const key = historyKey(session);
|
|
214
|
-
const forceDone = client._forceSnap && (!runActive || (client._snapBase ?? 0) > 0 && Date.now() - client._snapBase >= SNAP_STABLE_MS);
|
|
215
|
-
if (forceDone || (!runActive && key !== client._lastKey)) {
|
|
216
|
-
client._forceSnap = false;
|
|
217
|
-
client._lastKey = key;
|
|
218
|
-
send(client, { type: "snapshot", sessionID: sub.sid, name: sub.name, ...buildSnapshot(sub.sid, sub.name, entry) });
|
|
204
|
+
/** cmdDone 收尾:cmd 计数 + 广播 diff{done}(transcript) / meta(raw) */
|
|
205
|
+
const finalizeCommand = (sKey) => {
|
|
206
|
+
const [sid, name] = splitKey(sKey);
|
|
207
|
+
const b = streamBuf.get(sKey);
|
|
208
|
+
if (!b || !b.done)
|
|
209
|
+
return;
|
|
210
|
+
cmdCount.set(sKey, (cmdCount.get(sKey) ?? 0) + 1);
|
|
211
|
+
for (const c of subscribersOf(sid, name)) {
|
|
212
|
+
if (c._mode === "raw") {
|
|
213
|
+
send(c, { type: "meta", sessionID: sid, name, commands: cmdCount.get(sKey) });
|
|
219
214
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const r = session.readRawStream(client._rawPos);
|
|
223
|
-
if (r.data) {
|
|
224
|
-
client._rawPos = r.pos;
|
|
225
|
-
send(client, { type: "raw", data: r.data, pos: r.pos, reset: !!r.reset });
|
|
226
|
-
}
|
|
215
|
+
else {
|
|
216
|
+
send(c, { type: "diff", sessionID: sid, name, event: "done", exitCode: b.exitCode, endTs: b.endTs });
|
|
227
217
|
}
|
|
228
218
|
}
|
|
229
|
-
}
|
|
230
|
-
const
|
|
219
|
+
};
|
|
220
|
+
const splitKey = (key) => {
|
|
221
|
+
const sep = key.indexOf(":");
|
|
222
|
+
return [key.slice(0, sep), key.slice(sep + 1)];
|
|
223
|
+
};
|
|
224
|
+
/** 广播 sessions(json 变化去重;busy 翻转必然变化) */
|
|
225
|
+
const pushSessions = () => {
|
|
231
226
|
const payload = buildSessions();
|
|
232
227
|
const json = JSON.stringify(payload);
|
|
233
228
|
for (const client of wss.clients) {
|
|
@@ -238,7 +233,8 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
238
233
|
client._lastSessionsJson = json;
|
|
239
234
|
send(client, { type: "sessions", ...payload });
|
|
240
235
|
}
|
|
241
|
-
}
|
|
236
|
+
};
|
|
237
|
+
const statusTimer = setInterval(() => { pushSessions(); }, STATUS_PUSH_MS);
|
|
242
238
|
wss.on("connection", (ws) => {
|
|
243
239
|
ws.on("message", (raw) => {
|
|
244
240
|
let msg;
|
|
@@ -248,118 +244,269 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
248
244
|
catch {
|
|
249
245
|
return;
|
|
250
246
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
247
|
+
const t = msg.type;
|
|
248
|
+
if (!t)
|
|
249
|
+
return;
|
|
250
|
+
// ===== agent 上报(唯一数据源) =====
|
|
251
|
+
if (t === "register") {
|
|
252
|
+
const sessions = msg.sessions;
|
|
253
|
+
if (!Array.isArray(sessions))
|
|
254
|
+
return;
|
|
255
|
+
for (const old of agents.get(ws) ?? [])
|
|
256
|
+
agentBySession.delete(old);
|
|
257
|
+
agents.set(ws, new Set());
|
|
258
|
+
for (const s of sessions) {
|
|
259
|
+
const k = sessionKey(s.sessionID, s.name);
|
|
260
|
+
agents.get(ws).add(k);
|
|
261
|
+
agentBySession.set(k, ws);
|
|
257
262
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (t === "cmdStart") {
|
|
266
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
267
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
268
|
+
const command = typeof msg.command === "string" ? msg.command : "";
|
|
269
|
+
if (!sid || !command)
|
|
270
|
+
return;
|
|
271
|
+
const k = sessionKey(sid, name);
|
|
272
|
+
const existing = streamBuf.get(k);
|
|
273
|
+
streamBuf.set(k, {
|
|
274
|
+
data: existing && existing.done ? "" : existing?.data ?? "",
|
|
275
|
+
done: false,
|
|
276
|
+
ts: typeof msg.ts === "number" ? msg.ts : Date.now(),
|
|
277
|
+
command,
|
|
278
|
+
});
|
|
279
|
+
for (const c of subscribersOf(sid, name)) {
|
|
280
|
+
if (c._mode === "raw")
|
|
281
|
+
continue;
|
|
282
|
+
// 新命令:重置推送游标,通知命令开始
|
|
283
|
+
c._txPos = 0;
|
|
284
|
+
send(c, { type: "diff", sessionID: sid, name, event: "cmd", command, ts: streamBuf.get(k).ts });
|
|
285
|
+
}
|
|
286
|
+
pushSessions();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (t === "out") {
|
|
290
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
291
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
292
|
+
const data = typeof msg.data === "string" ? msg.data : "";
|
|
293
|
+
if (!sid || !data)
|
|
294
|
+
return;
|
|
295
|
+
const k = sessionKey(sid, name);
|
|
296
|
+
const isFinal = msg.final === true;
|
|
297
|
+
const existing = streamBuf.get(k);
|
|
298
|
+
if (isFinal) {
|
|
299
|
+
// 命令完成时 agent 补推的"处理后完整输出":替换(而非追加)避免与已推增量重复,
|
|
300
|
+
// 并强制推给所有 transcript 订阅者(重置游标 = 丢弃动画帧累积,用干净结果重建块)
|
|
301
|
+
streamBuf.set(k, { data, done: false, ts: Date.now(), command: existing?.command ?? "" });
|
|
302
|
+
for (const c of subscribersOf(sid, name)) {
|
|
303
|
+
if (c._mode === "raw")
|
|
304
|
+
continue;
|
|
305
|
+
c._txPos = data.length;
|
|
306
|
+
send(c, { type: "diff", sessionID: sid, name, event: "out", data, final: true });
|
|
281
307
|
}
|
|
282
|
-
break;
|
|
283
308
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
309
|
+
else if (existing) {
|
|
310
|
+
existing.data = (existing.data || "") + data;
|
|
311
|
+
existing.ts = Date.now();
|
|
287
312
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
break;
|
|
313
|
+
else {
|
|
314
|
+
streamBuf.set(k, { data, done: false, ts: Date.now(), command: "" });
|
|
291
315
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
entry.session.exec(command).then(() => {
|
|
302
|
-
// exec 完成后立即推送一次会话状态(busy 复位),不等 2s 定时器
|
|
303
|
-
const payload = buildSessions();
|
|
304
|
-
const json = JSON.stringify(payload);
|
|
305
|
-
for (const client of wss.clients) {
|
|
306
|
-
if (client.readyState !== WebSocket.OPEN)
|
|
307
|
-
continue;
|
|
308
|
-
client._lastSessionsJson = json;
|
|
309
|
-
send(client, { type: "sessions", ...payload });
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
// exec 前立即推送一次(busy 置位),让前端马上看到沙漏
|
|
313
|
-
const payload = buildSessions();
|
|
314
|
-
const json = JSON.stringify(payload);
|
|
315
|
-
for (const client of wss.clients) {
|
|
316
|
-
if (client.readyState !== WebSocket.OPEN)
|
|
317
|
-
continue;
|
|
318
|
-
client._lastSessionsJson = json;
|
|
319
|
-
send(client, { type: "sessions", ...payload });
|
|
316
|
+
const len = streamBuf.get(k).data.length;
|
|
317
|
+
for (const c of subscribersOf(sid, name)) {
|
|
318
|
+
if (c._mode === "raw")
|
|
319
|
+
continue;
|
|
320
|
+
const from = c._txPos ?? len;
|
|
321
|
+
const newData = streamBuf.get(k).data.slice(from);
|
|
322
|
+
if (newData) {
|
|
323
|
+
c._txPos = len;
|
|
324
|
+
send(c, { type: "diff", sessionID: sid, name, event: "out", data: newData });
|
|
320
325
|
}
|
|
321
|
-
break;
|
|
322
326
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (t === "cmdDone") {
|
|
330
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
331
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
332
|
+
if (!sid)
|
|
333
|
+
return;
|
|
334
|
+
const k = sessionKey(sid, name);
|
|
335
|
+
const b = streamBuf.get(k);
|
|
336
|
+
if (b) {
|
|
337
|
+
if (b.done)
|
|
338
|
+
return; // 幂等:同一命令只收尾一次
|
|
339
|
+
b.done = true;
|
|
340
|
+
b.endTs = typeof msg.endTs === "number" ? msg.endTs : Date.now();
|
|
341
|
+
// agent 上报退出码可能与六条退出路径不一致(无退出来源 null)
|
|
342
|
+
if (typeof msg.exitCode === "number")
|
|
343
|
+
b.exitCode = msg.exitCode;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
streamBuf.set(k, { data: "", done: true, ts: Date.now(), endTs: typeof msg.endTs === "number" ? msg.endTs : Date.now(), command: "" });
|
|
347
|
+
}
|
|
348
|
+
finalizeCommand(k);
|
|
349
|
+
pushSessions();
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (t === "raw") {
|
|
353
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
354
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
355
|
+
const data = typeof msg.data === "string" ? msg.data : "";
|
|
356
|
+
const pos = typeof msg.pos === "number" ? msg.pos : 0;
|
|
357
|
+
if (!sid)
|
|
358
|
+
return;
|
|
359
|
+
const k = sessionKey(sid, name);
|
|
360
|
+
const existing = rawBuf.get(k);
|
|
361
|
+
if (msg.reset === true || !existing)
|
|
362
|
+
rawBuf.set(k, { data, pos });
|
|
363
|
+
else if (data)
|
|
364
|
+
rawBuf.set(k, { data: existing.data + data, pos });
|
|
365
|
+
// 直接转发给 raw 订阅者(agent 推的已是增量、WS 保序)
|
|
366
|
+
for (const c of subscribersOf(sid, name)) {
|
|
367
|
+
if (c._mode !== "raw")
|
|
368
|
+
continue;
|
|
369
|
+
send(c, { type: "raw", data, pos, reset: msg.reset === true });
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// ===== web 请求 =====
|
|
374
|
+
if (t === "subscribe") {
|
|
375
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
376
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
377
|
+
if (!sid)
|
|
378
|
+
return;
|
|
379
|
+
ws._sub = { sid, name };
|
|
380
|
+
const sKey = sessionKey(sid, name);
|
|
381
|
+
const snap = buildSnapshot(sid, name);
|
|
382
|
+
if (snap.notFound) {
|
|
383
|
+
send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
|
|
384
|
+
ws._mode = "transcript";
|
|
385
|
+
return;
|
|
334
386
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (!
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
387
|
+
const wantRaw = msg.mode === "raw" || msg.raw === true;
|
|
388
|
+
if (wantRaw) {
|
|
389
|
+
// raw:不重发 pairs 全量,raw 全量(reset) 已含画面 + 轻量命令计数
|
|
390
|
+
if (!cmdCount.has(sKey))
|
|
391
|
+
cmdCount.set(sKey, snap.pairs.filter((p) => p.type === "cmd").length);
|
|
392
|
+
send(ws, { type: "meta", sessionID: sid, name, commands: cmdCount.get(sKey) });
|
|
393
|
+
const r = rawBuf.get(sKey);
|
|
394
|
+
if (r?.data)
|
|
395
|
+
send(ws, { type: "raw", data: r.data, pos: r.pos, reset: true });
|
|
396
|
+
else {
|
|
397
|
+
// 离线历史会话无实时 rawBuf:用 history 重建原始流供 raw 视图(rawActive 后画面即它)
|
|
398
|
+
const hist = buildRawFromHistory(snap.pairs);
|
|
399
|
+
if (hist)
|
|
400
|
+
send(ws, { type: "raw", data: hist, pos: hist.length, reset: true });
|
|
344
401
|
}
|
|
402
|
+
ws._mode = "raw";
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
// transcript:发完整基线,游标对齐缓冲末尾(基线已含全部缓冲内容,后续 out 只推增量)
|
|
406
|
+
send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
|
|
407
|
+
ws._txPos = streamBuf.get(sKey)?.data.length ?? 0;
|
|
408
|
+
ws._mode = "transcript";
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (t === "setMode") {
|
|
413
|
+
if (!ws._sub)
|
|
414
|
+
return;
|
|
415
|
+
const on = msg.mode === "raw";
|
|
416
|
+
const { sid, name } = ws._sub;
|
|
417
|
+
const sKey = sessionKey(sid, name);
|
|
418
|
+
if (on) {
|
|
419
|
+
ws._mode = "raw";
|
|
420
|
+
const snap = buildSnapshot(sid, name);
|
|
421
|
+
cmdCount.set(sKey, snap.pairs.filter((p) => p.type === "cmd").length);
|
|
422
|
+
send(ws, { type: "meta", sessionID: sid, name, commands: cmdCount.get(sKey) });
|
|
423
|
+
const r = rawBuf.get(sKey);
|
|
424
|
+
if (r?.data)
|
|
425
|
+
send(ws, { type: "raw", data: r.data, pos: r.pos, reset: true });
|
|
345
426
|
else {
|
|
346
|
-
//
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
try {
|
|
351
|
-
rmSync(histDir, { recursive: true, force: true });
|
|
352
|
-
}
|
|
353
|
-
catch { /* 忽略 */ }
|
|
354
|
-
try {
|
|
355
|
-
rmSync(sshDir, { recursive: true, force: true });
|
|
356
|
-
}
|
|
357
|
-
catch { /* 忽略 */ }
|
|
427
|
+
// 离线历史会话无实时 rawBuf:用 history 重建原始流供 raw 视图
|
|
428
|
+
const hist = buildRawFromHistory(snap.pairs);
|
|
429
|
+
if (hist)
|
|
430
|
+
send(ws, { type: "raw", data: hist, pos: hist.length, reset: true });
|
|
358
431
|
}
|
|
359
|
-
removeSessionState(dir, sid, name);
|
|
360
|
-
log.info(`Web 页删除终端 ${sid}/${name}`);
|
|
361
|
-
break;
|
|
362
432
|
}
|
|
433
|
+
else {
|
|
434
|
+
// raw→transcript:补发完整快照基线,游标对齐(raw 期间未收 diff)
|
|
435
|
+
ws._mode = "transcript";
|
|
436
|
+
const snap = buildSnapshot(sid, name);
|
|
437
|
+
ws._txPos = streamBuf.get(sKey)?.data.length ?? 0;
|
|
438
|
+
send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (t === "unsubscribe") {
|
|
443
|
+
ws._sub = undefined;
|
|
444
|
+
ws._mode = undefined;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
if (t === "ping") {
|
|
448
|
+
send(ws, { type: "pong" });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (t === "exec") {
|
|
452
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
453
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
454
|
+
const command = typeof msg.command === "string" ? msg.command : "";
|
|
455
|
+
if (!sid || !command)
|
|
456
|
+
return;
|
|
457
|
+
const agent = agentBySession.get(sessionKey(sid, name));
|
|
458
|
+
if (!agent)
|
|
459
|
+
return;
|
|
460
|
+
// 保留运行中流的累积输出:REPL 交互场景下 web 每次回车仍发 exec(非独立命令,无 cmdStart 重置游标),
|
|
461
|
+
// 无条件清空 streamBuf 会让订阅者 _txPos 超出新流长度,后续增量 slice 为空 → transcript 输出丢失。
|
|
462
|
+
// 与 cmdStart 语义一致:仅上一条已完成时清空,进行中(REPL 交互等待)保留累积。
|
|
463
|
+
const k = sessionKey(sid, name);
|
|
464
|
+
const existing = streamBuf.get(k);
|
|
465
|
+
streamBuf.set(k, { data: existing && existing.done ? "" : existing?.data ?? "", done: false, ts: Date.now(), command });
|
|
466
|
+
pushSessions();
|
|
467
|
+
send(agent, { type: "run-exec", reqId: `${sid}:${name}:${Date.now()}`, sessionID: sid, name, command });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (t === "send") {
|
|
471
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
472
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
473
|
+
const text = typeof msg.text === "string" ? msg.text : "";
|
|
474
|
+
if (!sid || !text)
|
|
475
|
+
return;
|
|
476
|
+
const agent = agentBySession.get(sessionKey(sid, name));
|
|
477
|
+
if (agent)
|
|
478
|
+
send(agent, { type: "run-send", sessionID: sid, name, text });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (t === "deleteTerminal") {
|
|
482
|
+
const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
|
|
483
|
+
const name = typeof msg.name === "string" ? msg.name : "";
|
|
484
|
+
if (!sid || !name)
|
|
485
|
+
return;
|
|
486
|
+
const agent = agentBySession.get(sessionKey(sid, name));
|
|
487
|
+
if (agent)
|
|
488
|
+
send(agent, { type: "run-delete", sessionID: sid, name });
|
|
489
|
+
removeSessionState(dir, sid, name);
|
|
490
|
+
// 删除该终端的 history 目录:buildSessions 会按 history 目录兜底列出离线历史终端,
|
|
491
|
+
// 只删 state 文件不清目录会导致"删除后页面仍在"(残留被兜底扫描重新发现)
|
|
492
|
+
try {
|
|
493
|
+
const histName = resolveHistName(sid, name);
|
|
494
|
+
rmSync(join(dir, sid, histName), { recursive: true, force: true });
|
|
495
|
+
}
|
|
496
|
+
catch { /* 目录不存在忽略 */ }
|
|
497
|
+
streamBuf.delete(sessionKey(sid, name));
|
|
498
|
+
rawBuf.delete(sessionKey(sid, name));
|
|
499
|
+
log.info(`Web 页删除终端 ${sid}/${name}`);
|
|
500
|
+
pushSessions();
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
ws.on("close", () => {
|
|
505
|
+
const reg = agents.get(ws);
|
|
506
|
+
if (reg) {
|
|
507
|
+
for (const k of reg)
|
|
508
|
+
agentBySession.delete(k);
|
|
509
|
+
agents.delete(ws);
|
|
363
510
|
}
|
|
364
511
|
});
|
|
365
512
|
});
|
|
@@ -373,41 +520,26 @@ export function startServer(port, getSessions, dir, lang = "en") {
|
|
|
373
520
|
server,
|
|
374
521
|
port: actualPort,
|
|
375
522
|
url: `http://127.0.0.1:${actualPort}`,
|
|
376
|
-
close: () => {
|
|
377
|
-
clearInterval(streamTimer);
|
|
378
|
-
clearInterval(statusTimer);
|
|
379
|
-
server.close();
|
|
380
|
-
wss.close();
|
|
381
|
-
},
|
|
523
|
+
close: () => { clearInterval(statusTimer); server.close(); wss.close(); },
|
|
382
524
|
});
|
|
383
525
|
});
|
|
384
526
|
});
|
|
385
527
|
}
|
|
386
|
-
/**
|
|
387
|
-
* 渲染终端记录页面:读取编译后的 index.html,替换 i18n 占位符与前端 I18N 对象
|
|
388
|
-
* @param lang 界面语言
|
|
389
|
-
* @param template index.html 模板文本(启动时已读)
|
|
390
|
-
* @returns 最终 HTML 文本
|
|
391
|
-
*/
|
|
392
528
|
function renderPage(lang, template) {
|
|
393
529
|
let out = template;
|
|
394
|
-
// 语言占位
|
|
395
530
|
out = out.replaceAll("{{lang}}", lang);
|
|
396
|
-
// 文本占位({{web_xxx}})
|
|
397
531
|
for (const key of PAGE_KEYS) {
|
|
398
532
|
out = out.replaceAll(`{{${key}}}`, tr(key, lang));
|
|
399
533
|
}
|
|
400
|
-
// 前端 JS I18N 对象(window.__I18N__ = ...)
|
|
401
534
|
const i18nObj = {};
|
|
402
535
|
for (const [jsKey, i18nKey] of Object.entries(JS_I18N_KEYS)) {
|
|
403
536
|
i18nObj[jsKey] = tr(i18nKey, lang);
|
|
404
537
|
}
|
|
405
538
|
out = out.replaceAll("__I18N_JSON__", JSON.stringify(i18nObj));
|
|
406
|
-
// PTY 列宽注入(前端 TermScreen 按此列宽渲染,与后端生成 ANSI 的列宽一致,避免错行)
|
|
407
539
|
out = out.replaceAll("__PTY_COLS_VAL__", String(PTY_COLS));
|
|
540
|
+
out = out.replaceAll("__PTY_ROWS_VAL__", String(PTY_ROWS));
|
|
408
541
|
return out;
|
|
409
542
|
}
|
|
410
|
-
/** 读取前端模板(dist/web/index.html);缺失时返回引导构建的提示页 */
|
|
411
543
|
function readTemplate(webDir) {
|
|
412
544
|
const file = join(webDir, "index.html");
|
|
413
545
|
if (!existsSync(file)) {
|
|
@@ -415,7 +547,6 @@ function readTemplate(webDir) {
|
|
|
415
547
|
}
|
|
416
548
|
return readFileSync(file, "utf8");
|
|
417
549
|
}
|
|
418
|
-
/** 静态文件类型映射 */
|
|
419
550
|
const MIME = {
|
|
420
551
|
".html": "text/html; charset=utf-8",
|
|
421
552
|
".js": "application/javascript; charset=utf-8",
|
|
@@ -427,12 +558,6 @@ const MIME = {
|
|
|
427
558
|
".png": "image/png",
|
|
428
559
|
".ico": "image/x-icon",
|
|
429
560
|
};
|
|
430
|
-
/**
|
|
431
|
-
* 分发静态资源(防目录穿越:归一化后校验目标在 webDir 内)
|
|
432
|
-
* @param res 响应对象
|
|
433
|
-
* @param webDir 前端产物根目录
|
|
434
|
-
* @param rel 相对路径(已去 /web/ 前缀)
|
|
435
|
-
*/
|
|
436
561
|
function serveStatic(res, webDir, rel) {
|
|
437
562
|
if (rel.includes("\0")) {
|
|
438
563
|
res.writeHead(400);
|
|
@@ -454,13 +579,6 @@ function serveStatic(res, webDir, rel) {
|
|
|
454
579
|
res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
|
|
455
580
|
res.end(readFileSync(file));
|
|
456
581
|
}
|
|
457
|
-
/**
|
|
458
|
-
* 从历史文件读取某终端记录(跨进程场景:本进程无该会话句柄时用)
|
|
459
|
-
* @param dir 插件缓存根目录
|
|
460
|
-
* @param sessionID opencode 会话 ID
|
|
461
|
-
* @param name 终端名(已含 local- 前缀则按本地目录)
|
|
462
|
-
* @returns 结构化消息对(保留 ANSI);目录不存在返回 null
|
|
463
|
-
*/
|
|
464
582
|
function readHistoryFromFile(dir, sessionID, name) {
|
|
465
583
|
const hdir = join(dir, sessionID, name);
|
|
466
584
|
let files;
|
|
@@ -485,9 +603,33 @@ function readHistoryFromFile(dir, sessionID, name) {
|
|
|
485
603
|
out.push({ type: "out", text: data.output });
|
|
486
604
|
}
|
|
487
605
|
catch {
|
|
488
|
-
/*
|
|
606
|
+
/* skip */
|
|
489
607
|
}
|
|
490
608
|
}
|
|
491
609
|
return out;
|
|
492
610
|
}
|
|
611
|
+
/**
|
|
612
|
+
* 从历史消息对重建原始字节流(raw 视图):拼接所有命令对为 `命令\n输出` 流。
|
|
613
|
+
* 离线历史会话无实时 rawBuf 时供 raw 模式显示。
|
|
614
|
+
* @param pairs 历史命令/输出对(snapshot 基线)
|
|
615
|
+
* @returns 重建的原始流
|
|
616
|
+
*/
|
|
617
|
+
function buildRawFromHistory(pairs) {
|
|
618
|
+
let raw = "";
|
|
619
|
+
for (const p of pairs) {
|
|
620
|
+
if (p.type === "cmd") {
|
|
621
|
+
raw += p.text + "\r\n";
|
|
622
|
+
}
|
|
623
|
+
else if (p.type === "out" || p.type === "run") {
|
|
624
|
+
raw += p.text;
|
|
625
|
+
// 输出末段若未换行则补一个换行,分隔下一条命令
|
|
626
|
+
if (!p.text.endsWith("\n") && !p.text.endsWith("\r"))
|
|
627
|
+
raw += "\r\n";
|
|
628
|
+
}
|
|
629
|
+
else if (p.type === "sep") {
|
|
630
|
+
raw += "\r\n";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return raw;
|
|
634
|
+
}
|
|
493
635
|
//# sourceMappingURL=server.js.map
|