@xiaoqiong0v0/opencode-ssh-tool 0.5.0 → 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.
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 +250 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/base-session.d.ts +79 -13
  7. package/dist/base-session.d.ts.map +1 -1
  8. package/dist/base-session.js +246 -100
  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 +32 -14
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +39 -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 +0 -1
  30. package/dist/local-session.d.ts.map +1 -1
  31. package/dist/local-session.js +12 -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 +109 -20
  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 +424 -224
  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 +40 -2
  50. package/dist/shell-adapter.d.ts.map +1 -1
  51. package/dist/shell-adapter.js +252 -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 +30 -9
  56. package/dist/utils.js.map +1 -1
  57. package/dist/web/app.css +20 -7
  58. package/dist/web/app.js +11 -2
  59. package/dist/web/index.html +9 -2
  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",
@@ -18,38 +18,30 @@ const PAGE_KEYS = [
18
18
  "web_terminals",
19
19
  "web_local",
20
20
  "web_delete_terminal",
21
+ "web_cmd_placeholder",
22
+ "web_send_ctrlc",
23
+ "web_raw",
21
24
  ];
22
- /** 前端 JS 内 I18N 对象键 → i18n key */
23
25
  const JS_I18N_KEYS = {
24
- run: "web_running",
25
26
  commands: "web_commands",
26
- autoRefresh: "web_auto_refresh",
27
27
  sessionGone: "web_session_gone",
28
28
  noSession: "web_no_session",
29
- loadFailed: "web_load_failed",
30
29
  local: "web_local",
31
30
  terminals: "web_terminals",
31
+ cmdPlaceholder: "web_cmd_placeholder",
32
+ sendCtrlC: "web_send_ctrlc",
32
33
  };
33
- /** WS 运行增量轮询间隔 */
34
- const STREAM_TICK_MS = 100;
35
- /** runEnd 后等待快照稳定(后台哨兵监听写入 history)的缓冲时间 */
36
- const SNAP_STABLE_MS = 400;
37
- /** 会话列表推送间隔(服务端主动推送,非浏览器轮询) */
38
34
  const STATUS_PUSH_MS = 2000;
39
- /**
40
- * 启动 HTTP + WebSocket 服务(监听 127.0.0.1)
41
- * @param port 端口,0 或未指定 → 系统随机分配(避免冲突)
42
- * @param getSessions 获取扁平会话条目列表的函数(供本进程页面展示与实时流)
43
- * @param dir 插件缓存根目录(跨进程状态文件/历史文件所在处)
44
- * @param lang Web 界面语言(默认 en)
45
- * @returns 服务句柄(含实际端口与 URL)
46
- */
47
- 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) {
48
37
  let actualPort = port;
49
- // 前端产物目录:dist/web(相对本模块 dist/server.js 的上一级)
50
38
  const webDir = join(dirname(fileURLToPath(import.meta.url)), "web");
51
- // 模板只读一次(dist/web/index.html),每次请求做 i18n 占位替换
52
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();
53
45
  const server = createServer((req, res) => {
54
46
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
55
47
  const path = url.pathname;
@@ -64,7 +56,6 @@ export function startServer(port, getSessions, dir, lang = "en") {
64
56
  res.end(html);
65
57
  return;
66
58
  }
67
- // 静态资源:/web/* → dist/web/*(JS/CSS/字体)
68
59
  if (path.startsWith("/web/")) {
69
60
  serveStatic(res, webDir, decodeURIComponent(path.slice("/web/".length)));
70
61
  return;
@@ -72,7 +63,6 @@ export function startServer(port, getSessions, dir, lang = "en") {
72
63
  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
73
64
  res.end("Not Found");
74
65
  });
75
- // ===== WebSocket:会话列表 + transcript 快照 + 运行中增量 =====
76
66
  const wss = new WebSocketServer({ noServer: true });
77
67
  server.on("upgrade", (req, socket, head) => {
78
68
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -80,24 +70,77 @@ export function startServer(port, getSessions, dir, lang = "en") {
80
70
  socket.destroy();
81
71
  return;
82
72
  }
83
- wss.handleUpgrade(req, socket, head, (ws) => {
84
- wss.emit("connection", ws, req);
85
- });
73
+ wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); });
86
74
  });
87
- // 发送 JSON 消息给客户端(连接未开则忽略)
88
75
  const send = (ws, msg) => {
89
76
  if (ws.readyState === WebSocket.OPEN)
90
77
  ws.send(JSON.stringify(msg));
91
78
  };
92
- /** 构建聚合会话列表(与旧 /api/status 同构,供 WS sessions 消息推送) */
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
+ };
93
83
  const buildSessions = () => {
94
84
  const states = listAllSessions(dir);
95
- // 用 live 连接修正 stale 状态:进程复苏后旧状态文件里的 connected=true 实为已断开
96
- const liveKey = new Set(getSessions().map((e) => `${e.sessionID}:${e.name}`));
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);
134
+ }
97
135
  for (const s of states) {
98
- if (s.connected && !liveKey.has(`${s.sessionID}:${s.name}`)) {
136
+ if (s.connected && !liveKey.has(sessionKey(s.sessionID, s.name))) {
99
137
  s.connected = false;
100
138
  }
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));
101
144
  }
102
145
  const bySession = new Map();
103
146
  for (const s of states) {
@@ -105,114 +148,81 @@ export function startServer(port, getSessions, dir, lang = "en") {
105
148
  bySession.set(s.sessionID, { title: s.title, directory: s.directory, terminals: [] });
106
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 });
107
150
  }
108
- const sessions = [...bySession.entries()].map(([sessionID, v]) => ({
109
- sessionID,
110
- title: v.title,
111
- directory: v.directory,
112
- terminals: v.terminals,
113
- }));
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 }));
114
162
  return { port: actualPort, sessions };
115
163
  };
116
- /** 构建某终端的 transcript 快照(本进程句柄直读内存;跨进程回退读历史文件) */
117
- const buildSnapshot = (sid, name, entry) => {
118
- if (entry) {
119
- const pairs = [];
120
- for (const pair of entry.session.getHistory().getPairs()) {
121
- if (pair.command === "__SSH_SEP__") {
122
- pairs.push({ type: "sep", ts: pair.ts, text: "" });
123
- }
124
- else {
125
- pairs.push({ type: "cmd", ts: pair.ts, endTs: pair.endTs, text: pair.command });
126
- pairs.push({ type: "out", text: entry.session.getHistory().readOutput(pair) });
127
- }
128
- }
129
- // 运行中:命令文本 + 当前输出(前端可立即显示命令行,再跟动画)
130
- if (entry.session.hasRunningStream()) {
131
- const runningCmd = entry.session.getRunningCommand();
132
- if (runningCmd)
133
- pairs.push({ type: "cmd", ts: Date.now(), text: runningCmd });
134
- const running = entry.session.getRunningOutput();
135
- if (running)
136
- pairs.push({ type: "run", text: running });
137
- }
138
- 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"));
139
179
  }
140
- // 本进程无此会话句柄(跨进程会话/复用服务)→ 从历史文件读取
180
+ catch { /* 目录不存在 */ }
181
+ if (hasLocal && !hasPlain)
182
+ return `local-${name}`;
183
+ if (hasPlain && !hasLocal)
184
+ return name;
185
+ // 两者都不存在 → 沿用 state 判定(在线 local 会话刚建历史未落盘时)
141
186
  const state = listAllSessions(dir).find((s) => s.sessionID === sid && s.name === name);
142
- const histName = state?.kind === "local" ? `local-${name}` : name;
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);
143
192
  const pairs = readHistoryFromFile(dir, sid, histName) ?? [];
144
- if (pairs.length === 0 && !state) {
145
- // 状态文件与历史都消失 = 会话真正删除,前端剔除该幽灵终端
146
- return { pairs: [], notFound: true };
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 });
147
199
  }
200
+ if (pairs.length === 0 && !state)
201
+ return { pairs: [], notFound: true };
148
202
  return { pairs };
149
203
  };
150
- /** 会话历史指纹(含末对序号+字节数),用于判断是否需要重推快照 */
151
- const historyKey = (session) => {
152
- const hp = session.getHistory().getPairs();
153
- const last = hp[hp.length - 1];
154
- return `${hp.length}:${last ? `${last.seq}:${last.size}` : "-"}`;
155
- };
156
- // WS 定时推送:状态列表(低频)+ 订阅终端的运行增量/快照(高频)
157
- const streamTimer = setInterval(() => {
158
- for (const client of wss.clients) {
159
- if (client.readyState !== WebSocket.OPEN)
160
- continue;
161
- const sub = client._sub;
162
- if (!sub)
163
- continue;
164
- const entry = getSessions().find((e) => e.sessionID === sub.sid && e.name === sub.name);
165
- const session = entry?.session;
166
- if (!session) {
167
- // 跨进程会话(无本进程句柄):仅在有变化时推文件级快照(低频,退化无实时流)
168
- const snap = buildSnapshot(sub.sid, sub.name);
169
- const key = JSON.stringify(snap);
170
- if (key !== client._lastKey) {
171
- client._lastKey = key;
172
- send(client, { type: "snapshot", sessionID: sub.sid, name: sub.name, ...snap });
173
- }
174
- continue;
175
- }
176
- // 命令开始运行:检测从空闲→运行切换,立即推送命令文本(前端先渲染命令行,动画紧跟其后)
177
- const runActive = session.hasRunningStream();
178
- if (runActive && !client._wasRunning) {
179
- const cmd = session.getRunningCommand();
180
- if (cmd)
181
- send(client, { type: "cmdStart", command: cmd });
182
- }
183
- client._wasRunning = runActive;
184
- // 运行增量推送(cmdStart 之后,保证前端先建命令行再建 run 块)
185
- const st = session.getRunningStream();
186
- if (st.done) {
187
- // 仅首次转 done 时发 runEnd,避免后续 tick 重复发送重置 snapBase
188
- if (!client._forceSnap) {
189
- send(client, { type: "runEnd" });
190
- client._forceSnap = true;
191
- client._snapBase = Date.now();
192
- }
193
- }
194
- else if (st.data) {
195
- send(client, { type: "run", data: st.data });
196
- }
197
- // 快照推送:runEnd 后等后台写入稳定,或空闲期历史变化(exec 同步完成)
198
- const key = historyKey(session);
199
- const forceDone = client._forceSnap && (!runActive || (client._snapBase ?? 0) > 0 && Date.now() - client._snapBase >= SNAP_STABLE_MS);
200
- if (forceDone || (!runActive && key !== client._lastKey)) {
201
- client._forceSnap = false;
202
- client._lastKey = key;
203
- 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) });
204
214
  }
205
- // Raw 连续流增量推送(订阅时已发全量,此后只推新字节)
206
- if (client._rawPos !== undefined) {
207
- const r = session.readRawStream(client._rawPos);
208
- if (r.data) {
209
- client._rawPos = r.pos;
210
- send(client, { type: "raw", data: r.data, pos: r.pos, reset: !!r.reset });
211
- }
215
+ else {
216
+ send(c, { type: "diff", sessionID: sid, name, event: "done", exitCode: b.exitCode, endTs: b.endTs });
212
217
  }
213
218
  }
214
- }, STREAM_TICK_MS);
215
- const statusTimer = setInterval(() => {
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 = () => {
216
226
  const payload = buildSessions();
217
227
  const json = JSON.stringify(payload);
218
228
  for (const client of wss.clients) {
@@ -223,7 +233,8 @@ export function startServer(port, getSessions, dir, lang = "en") {
223
233
  client._lastSessionsJson = json;
224
234
  send(client, { type: "sessions", ...payload });
225
235
  }
226
- }, STATUS_PUSH_MS);
236
+ };
237
+ const statusTimer = setInterval(() => { pushSessions(); }, STATUS_PUSH_MS);
227
238
  wss.on("connection", (ws) => {
228
239
  ws.on("message", (raw) => {
229
240
  let msg;
@@ -233,75 +244,269 @@ export function startServer(port, getSessions, dir, lang = "en") {
233
244
  catch {
234
245
  return;
235
246
  }
236
- switch (msg.type) {
237
- case "list": {
238
- const payload = buildSessions();
239
- ws._lastSessionsJson = JSON.stringify(payload);
240
- send(ws, { type: "sessions", ...payload });
241
- break;
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);
242
262
  }
243
- case "subscribe": {
244
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
245
- const name = typeof msg.name === "string" ? msg.name : "default";
246
- if (!sid)
247
- return;
248
- ws._sub = { sid, name };
249
- ws._forceSnap = false;
250
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
251
- ws._wasRunning = entry?.session.hasRunningStream() ?? false;
252
- // 预置流指针:运行中订阅时先消费一次(丢弃),快照已含当前输出,后续增量不与快照重叠
253
- entry?.session.getRunningStream();
254
- const snap = buildSnapshot(sid, name, entry);
255
- ws._lastKey = entry ? historyKey(entry.session) : JSON.stringify(snap);
256
- send(ws, { type: "snapshot", sessionID: sid, name, ...snap });
257
- // Raw 连续流:仅客户端要求且本进程持有会话句柄时启用
258
- const wantRaw = msg.raw === true;
259
- if (wantRaw && entry) {
260
- const r = entry.session.readRawStream(0);
261
- ws._rawPos = r.pos;
262
- send(ws, { type: "raw", data: r.data, pos: r.pos, reset: true });
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 });
263
307
  }
264
- else {
265
- ws._rawPos = undefined;
308
+ }
309
+ else if (existing) {
310
+ existing.data = (existing.data || "") + data;
311
+ existing.ts = Date.now();
312
+ }
313
+ else {
314
+ streamBuf.set(k, { data, done: false, ts: Date.now(), command: "" });
315
+ }
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 });
266
325
  }
267
- break;
268
326
  }
269
- case "unsubscribe": {
270
- ws._sub = undefined;
271
- break;
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: "" });
272
347
  }
273
- case "ping": {
274
- send(ws, { type: "pong" });
275
- break;
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;
276
386
  }
277
- case "deleteTerminal": {
278
- const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
279
- const name = typeof msg.name === "string" ? msg.name : "";
280
- if (!sid || !name)
281
- break;
282
- const entry = getSessions().find((e) => e.sessionID === sid && e.name === name);
283
- if (entry) {
284
- // 有活动句柄:close() 已含 history.dispose() 删除历史目录
285
- entry.session.close();
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 });
286
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 });
287
426
  else {
288
- // 跨进程/陈旧终端:无句柄,直接删除历史目录(历史文件 + 会话记录)
289
- const histName = `local-${name}`;
290
- const histDir = join(dir, sid, histName);
291
- const sshDir = join(dir, sid, name);
292
- try {
293
- rmSync(histDir, { recursive: true, force: true });
294
- }
295
- catch { /* 忽略 */ }
296
- try {
297
- rmSync(sshDir, { recursive: true, force: true });
298
- }
299
- 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 });
300
431
  }
301
- removeSessionState(dir, sid, name);
302
- log.info(`Web 页删除终端 ${sid}/${name}`);
303
- break;
304
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);
305
510
  }
306
511
  });
307
512
  });
@@ -315,41 +520,26 @@ export function startServer(port, getSessions, dir, lang = "en") {
315
520
  server,
316
521
  port: actualPort,
317
522
  url: `http://127.0.0.1:${actualPort}`,
318
- close: () => {
319
- clearInterval(streamTimer);
320
- clearInterval(statusTimer);
321
- server.close();
322
- wss.close();
323
- },
523
+ close: () => { clearInterval(statusTimer); server.close(); wss.close(); },
324
524
  });
325
525
  });
326
526
  });
327
527
  }
328
- /**
329
- * 渲染终端记录页面:读取编译后的 index.html,替换 i18n 占位符与前端 I18N 对象
330
- * @param lang 界面语言
331
- * @param template index.html 模板文本(启动时已读)
332
- * @returns 最终 HTML 文本
333
- */
334
528
  function renderPage(lang, template) {
335
529
  let out = template;
336
- // 语言占位
337
530
  out = out.replaceAll("{{lang}}", lang);
338
- // 文本占位({{web_xxx}})
339
531
  for (const key of PAGE_KEYS) {
340
532
  out = out.replaceAll(`{{${key}}}`, tr(key, lang));
341
533
  }
342
- // 前端 JS I18N 对象(window.__I18N__ = ...)
343
534
  const i18nObj = {};
344
535
  for (const [jsKey, i18nKey] of Object.entries(JS_I18N_KEYS)) {
345
536
  i18nObj[jsKey] = tr(i18nKey, lang);
346
537
  }
347
538
  out = out.replaceAll("__I18N_JSON__", JSON.stringify(i18nObj));
348
- // PTY 列宽注入(前端 TermScreen 按此列宽渲染,与后端生成 ANSI 的列宽一致,避免错行)
349
539
  out = out.replaceAll("__PTY_COLS_VAL__", String(PTY_COLS));
540
+ out = out.replaceAll("__PTY_ROWS_VAL__", String(PTY_ROWS));
350
541
  return out;
351
542
  }
352
- /** 读取前端模板(dist/web/index.html);缺失时返回引导构建的提示页 */
353
543
  function readTemplate(webDir) {
354
544
  const file = join(webDir, "index.html");
355
545
  if (!existsSync(file)) {
@@ -357,7 +547,6 @@ function readTemplate(webDir) {
357
547
  }
358
548
  return readFileSync(file, "utf8");
359
549
  }
360
- /** 静态文件类型映射 */
361
550
  const MIME = {
362
551
  ".html": "text/html; charset=utf-8",
363
552
  ".js": "application/javascript; charset=utf-8",
@@ -369,12 +558,6 @@ const MIME = {
369
558
  ".png": "image/png",
370
559
  ".ico": "image/x-icon",
371
560
  };
372
- /**
373
- * 分发静态资源(防目录穿越:归一化后校验目标在 webDir 内)
374
- * @param res 响应对象
375
- * @param webDir 前端产物根目录
376
- * @param rel 相对路径(已去 /web/ 前缀)
377
- */
378
561
  function serveStatic(res, webDir, rel) {
379
562
  if (rel.includes("\0")) {
380
563
  res.writeHead(400);
@@ -396,13 +579,6 @@ function serveStatic(res, webDir, rel) {
396
579
  res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
397
580
  res.end(readFileSync(file));
398
581
  }
399
- /**
400
- * 从历史文件读取某终端记录(跨进程场景:本进程无该会话句柄时用)
401
- * @param dir 插件缓存根目录
402
- * @param sessionID opencode 会话 ID
403
- * @param name 终端名(已含 local- 前缀则按本地目录)
404
- * @returns 结构化消息对(保留 ANSI);目录不存在返回 null
405
- */
406
582
  function readHistoryFromFile(dir, sessionID, name) {
407
583
  const hdir = join(dir, sessionID, name);
408
584
  let files;
@@ -427,9 +603,33 @@ function readHistoryFromFile(dir, sessionID, name) {
427
603
  out.push({ type: "out", text: data.output });
428
604
  }
429
605
  catch {
430
- /* 跳过损坏文件 */
606
+ /* skip */
431
607
  }
432
608
  }
433
609
  return out;
434
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
+ }
435
635
  //# sourceMappingURL=server.js.map