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