@xiaoqiong0v0/opencode-ssh-tool 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +27 -21
  2. package/dist/agent.d.ts +52 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +244 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/base-session.d.ts +95 -13
  7. package/dist/base-session.d.ts.map +1 -1
  8. package/dist/base-session.js +271 -102
  9. package/dist/base-session.js.map +1 -1
  10. package/dist/config.d.ts +6 -2
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +16 -7
  13. package/dist/config.js.map +1 -1
  14. package/dist/constants.d.ts +14 -15
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +14 -15
  17. package/dist/constants.js.map +1 -1
  18. package/dist/i18n.d.ts +24 -14
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +37 -12
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +90 -73
  24. package/dist/index.js.map +1 -1
  25. package/dist/last-match.d.ts +11 -0
  26. package/dist/last-match.d.ts.map +1 -1
  27. package/dist/last-match.js +24 -0
  28. package/dist/last-match.js.map +1 -1
  29. package/dist/local-session.d.ts +2 -1
  30. package/dist/local-session.d.ts.map +1 -1
  31. package/dist/local-session.js +14 -8
  32. package/dist/local-session.js.map +1 -1
  33. package/dist/server-entry.d.ts +2 -0
  34. package/dist/server-entry.d.ts.map +1 -0
  35. package/dist/server-entry.js +61 -0
  36. package/dist/server-entry.js.map +1 -0
  37. package/dist/server-manager.d.ts +9 -9
  38. package/dist/server-manager.d.ts.map +1 -1
  39. package/dist/server-manager.js +208 -27
  40. package/dist/server-manager.js.map +1 -1
  41. package/dist/server.d.ts +3 -28
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +429 -275
  44. package/dist/server.js.map +1 -1
  45. package/dist/session.d.ts +0 -1
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +2 -9
  48. package/dist/session.js.map +1 -1
  49. package/dist/shell-adapter.d.ts +38 -2
  50. package/dist/shell-adapter.d.ts.map +1 -1
  51. package/dist/shell-adapter.js +372 -24
  52. package/dist/shell-adapter.js.map +1 -1
  53. package/dist/utils.d.ts +3 -1
  54. package/dist/utils.d.ts.map +1 -1
  55. package/dist/utils.js +29 -10
  56. package/dist/utils.js.map +1 -1
  57. package/dist/web/app.css +12 -7
  58. package/dist/web/app.js +11 -2
  59. package/dist/web/index.html +6 -3
  60. package/dist/web/xterm.css +218 -0
  61. package/package.json +3 -2
package/dist/server.js CHANGED
@@ -1,15 +1,15 @@
1
- // 本地 HTTP+WebSocket 服务:HTTP 提供页面/静态资源,WS 承载全部实时数据(会话列表 / transcript 快照 / 运行增量)
2
- // 前端页面为独立 web/ 工程(esbuild 打包到 dist/web/),本文件负责:i18n 占位替换 + 静态资源分发 + WS 协议实现
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
- * 启动 HTTP + WebSocket 服务(监听 127.0.0.1)
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
- /** 构建聚合会话列表(与旧 /api/status 同构,供 WS sessions 消息推送) */
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
- // 用 live 连接修正 stale 状态:进程复苏后旧状态文件里的 connected=true 实为已断开
100
- const liveKey = new Set(getSessions().map((e) => `${e.sessionID}:${e.name}`));
101
- // live 会话的实时 busy/connected(web 端 WS exec 不写状态文件,busy 需从内存读)
102
- const liveStatus = new Map();
103
- for (const e of getSessions()) {
104
- const st = e.session.getStatus();
105
- liveStatus.set(`${e.sessionID}:${e.name}`, { connected: st.connected, busy: st.busy });
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(`${s.sessionID}:${s.name}`)) {
140
+ if (s.connected && !liveKey.has(sessionKey(s.sessionID, s.name))) {
109
141
  s.connected = false;
110
142
  }
111
- const live = liveStatus.get(`${s.sessionID}:${s.name}`);
112
- if (live) {
113
- s.connected = live.connected;
114
- s.busy = live.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
- const sessions = [...bySession.entries()].map(([sessionID, v]) => ({
124
- sessionID,
125
- title: v.title,
126
- directory: v.directory,
127
- terminals: v.terminals,
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
- /** 构建某终端的 transcript 快照(本进程句柄直读内存;跨进程回退读历史文件) */
132
- const buildSnapshot = (sid, name, entry) => {
133
- if (entry) {
134
- const pairs = [];
135
- for (const pair of entry.session.getHistory().getPairs()) {
136
- if (pair.command === "__SSH_SEP__") {
137
- pairs.push({ type: "sep", ts: pair.ts, text: "" });
138
- }
139
- else {
140
- pairs.push({ type: "cmd", ts: pair.ts, endTs: pair.endTs, text: pair.command });
141
- pairs.push({ type: "out", text: entry.session.getHistory().readOutput(pair) });
142
- }
143
- }
144
- // 运行中:命令文本 + 当前输出(前端可立即显示命令行,再跟动画)
145
- if (entry.session.hasRunningStream()) {
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
- const histName = state?.kind === "local" ? `local-${name}` : name;
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
- if (pairs.length === 0 && !state) {
160
- // 状态文件与历史都消失 = 会话真正删除,前端剔除该幽灵终端
161
- return { pairs: [], notFound: true };
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 historyKey = (session) => {
167
- const hp = session.getHistory().getPairs();
168
- const last = hp[hp.length - 1];
169
- return `${hp.length}:${last ? `${last.seq}:${last.size}` : "-"}`;
170
- };
171
- // WS 定时推送:状态列表(低频)+ 订阅终端的运行增量/快照(高频)
172
- const streamTimer = setInterval(() => {
173
- for (const client of wss.clients) {
174
- if (client.readyState !== WebSocket.OPEN)
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
- // Raw 连续流增量推送(订阅时已发全量,此后只推新字节)
221
- if (client._rawPos !== undefined) {
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
- }, STREAM_TICK_MS);
230
- const statusTimer = setInterval(() => {
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
- }, STATUS_PUSH_MS);
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
- switch (msg.type) {
252
- case "list": {
253
- const payload = buildSessions();
254
- ws._lastSessionsJson = JSON.stringify(payload);
255
- send(ws, { type: "sessions", ...payload });
256
- break;
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
- case "subscribe": {
259
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
260
- const name = typeof msg.name === "string" ? msg.name : "default";
261
- if (!sid)
262
- return;
263
- ws._sub = { sid, name };
264
- ws._forceSnap = false;
265
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
266
- ws._wasRunning = entry?.session.hasRunningStream() ?? false;
267
- // 预置流指针:运行中订阅时先消费一次(丢弃),快照已含当前输出,后续增量不与快照重叠
268
- entry?.session.getRunningStream();
269
- const snap = buildSnapshot(sid, name, entry);
270
- ws._lastKey = entry ? historyKey(entry.session) : JSON.stringify(snap);
271
- send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
272
- // Raw 连续流:仅客户端要求且本进程持有会话句柄时启用
273
- const wantRaw = msg.raw === true;
274
- if (wantRaw && entry) {
275
- const r = entry.session.readRawStream(0);
276
- ws._rawPos = r.pos;
277
- send(ws, { type: "raw", data: r.data, pos: r.pos, reset: true });
278
- }
279
- else {
280
- ws._rawPos = undefined;
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
- case "unsubscribe": {
285
- ws._sub = undefined;
286
- break;
314
+ else if (existing) {
315
+ existing.data = (existing.data || "") + data;
316
+ existing.ts = Date.now();
287
317
  }
288
- case "ping": {
289
- send(ws, { type: "pong" });
290
- break;
318
+ else {
319
+ streamBuf.set(k, { data, done: false, ts: Date.now(), command: "" });
291
320
  }
292
- case "exec": {
293
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
294
- const name = typeof msg.name === "string" ? msg.name : "";
295
- const command = typeof msg.command === "string" ? msg.command : "";
296
- if (!sid || !command)
297
- break;
298
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
299
- if (!entry)
300
- break;
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
- case "send": {
324
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
325
- const name = typeof msg.name === "string" ? msg.name : "";
326
- const text = typeof msg.text === "string" ? msg.text : "";
327
- if (!sid || !text)
328
- break;
329
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
330
- if (!entry)
331
- break;
332
- entry.session.send(text);
333
- break;
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
- case "deleteTerminal": {
336
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
337
- const name = typeof msg.name === "string" ? msg.name : "";
338
- if (!sid || !name)
339
- break;
340
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
341
- if (entry) {
342
- // 有活动句柄:close() 已含 history.dispose() 删除历史目录
343
- entry.session.close();
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 histName = `local-${name}`;
348
- const histDir = join(dir, sid, histName);
349
- const sshDir = join(dir, sid, name);
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