@xiaoqiong0v0/opencode-ssh-tool 0.6.11 → 0.6.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +4 -1
  2. package/dist/agent.d.ts +2 -2
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +124 -24
  5. package/dist/agent.js.map +1 -1
  6. package/dist/base-session.d.ts +134 -24
  7. package/dist/base-session.d.ts.map +1 -1
  8. package/dist/base-session.js +479 -187
  9. package/dist/base-session.js.map +1 -1
  10. package/dist/constants.d.ts +62 -1
  11. package/dist/constants.d.ts.map +1 -1
  12. package/dist/constants.js +64 -1
  13. package/dist/constants.js.map +1 -1
  14. package/dist/escapes.d.ts +9 -0
  15. package/dist/escapes.d.ts.map +1 -0
  16. package/dist/escapes.js +65 -0
  17. package/dist/escapes.js.map +1 -0
  18. package/dist/i18n.d.ts +21 -5
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +7 -3
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -3
  24. package/dist/index.js.map +1 -1
  25. package/dist/local-session.d.ts.map +1 -1
  26. package/dist/local-session.js +2 -0
  27. package/dist/local-session.js.map +1 -1
  28. package/dist/log.d.ts +9 -0
  29. package/dist/log.d.ts.map +1 -1
  30. package/dist/log.js +28 -0
  31. package/dist/log.js.map +1 -1
  32. package/dist/server-entry.js +24 -14
  33. package/dist/server-entry.js.map +1 -1
  34. package/dist/server.d.ts +6 -0
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +310 -23
  37. package/dist/server.js.map +1 -1
  38. package/dist/session-store.d.ts +17 -2
  39. package/dist/session-store.d.ts.map +1 -1
  40. package/dist/session-store.js +78 -9
  41. package/dist/session-store.js.map +1 -1
  42. package/dist/session.d.ts.map +1 -1
  43. package/dist/session.js +16 -2
  44. package/dist/session.js.map +1 -1
  45. package/dist/shell-adapter.d.ts +13 -4
  46. package/dist/shell-adapter.d.ts.map +1 -1
  47. package/dist/shell-adapter.js +23 -17
  48. package/dist/shell-adapter.js.map +1 -1
  49. package/dist/utils.d.ts +16 -1
  50. package/dist/utils.d.ts.map +1 -1
  51. package/dist/utils.js +152 -27
  52. package/dist/utils.js.map +1 -1
  53. package/dist/web/app.css +6 -4
  54. package/dist/web/app.js +9 -9
  55. package/dist/web/index.html +4 -1
  56. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { WebSocketServer, WebSocket } from "ws";
9
9
  import { listAllSessions, removeSessionState } from "./session-store.js";
10
10
  import { tr } from "./i18n.js";
11
- import { PTY_COLS, PTY_ROWS } from "./constants.js";
11
+ import { PTY_COLS, PTY_ROWS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HEARTBEAT_CLIENT_TIMEOUT_MS, STATUS_PUSH_DEBOUNCE_MS, STATUS_PUSH_FALLBACK_MS, ORPHAN_STATE_GRACE_MS, ORPHAN_STATE_MIN_AGE_MS, } from "./constants.js";
12
12
  import log, { infoDedup } from "./log.js";
13
13
  const PAGE_KEYS = [
14
14
  "web_title",
@@ -19,7 +19,6 @@ const PAGE_KEYS = [
19
19
  "web_local",
20
20
  "web_delete_terminal",
21
21
  "web_cmd_placeholder",
22
- "web_send_ctrlc",
23
22
  "web_raw",
24
23
  ];
25
24
  const JS_I18N_KEYS = {
@@ -29,9 +28,12 @@ const JS_I18N_KEYS = {
29
28
  local: "web_local",
30
29
  terminals: "web_terminals",
31
30
  cmdPlaceholder: "web_cmd_placeholder",
32
- sendCtrlC: "web_send_ctrlc",
31
+ directInput: "web_direct_input",
32
+ inputOn: "web_input_on",
33
+ inputOff: "web_input_off",
34
+ inputDisconnected: "web_input_disconnected",
35
+ inputOffline: "web_input_offline",
33
36
  };
34
- const STATUS_PUSH_MS = 2000;
35
37
  const sessionKey = (sid, name) => `${sid}:${name}`;
36
38
  /**
37
39
  * 会话键(`sid:name`)转日志显示形式(`sid/name`):与终端侧 `[busy]`/`[run]` 行同一写法,
@@ -47,8 +49,33 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
47
49
  let actualPort = port;
48
50
  const webDir = join(dirname(fileURLToPath(import.meta.url)), "web");
49
51
  const pageTemplate = readTemplate(webDir);
52
+ /** 服务启动时刻(孤儿状态文件清理的启动宽限基准,见 ORPHAN_STATE_GRACE_MS) */
53
+ const serverStartedAt = Date.now();
54
+ /**
55
+ * 本服务是否绑定在固定端口(port>0)。
56
+ * port=0 表示每个 opencode 实例各自起一个随机端口服务,本服务只能看见连到自己的 agent,
57
+ * "无已连 agent 认领"无法区分孤儿与**别的实例正在使用的活会话**,故磁盘孤儿清理不安全(见 sweepOrphanStateFiles)。
58
+ */
59
+ const isFixedPortServer = port !== 0;
50
60
  const agents = new Map();
51
61
  const agentBySession = new Map();
62
+ /** 当前是否存在 agent 连接(边沿状态,供 notifyAgentPresence 去重) */
63
+ let agentPresent = false;
64
+ /** "存在 agent 连接"边沿的订阅者(server-entry 用于事件驱动空闲关闭) */
65
+ const presenceListeners = [];
66
+ /** 在 agents 集合发生"空 ↔ 非空"翻转时通知订阅者(无翻转则不发) */
67
+ const notifyAgentPresence = () => {
68
+ const present = agents.size > 0;
69
+ if (present === agentPresent)
70
+ return;
71
+ agentPresent = present;
72
+ for (const cb of presenceListeners) {
73
+ try {
74
+ cb(present);
75
+ }
76
+ catch { /* 订阅回调异常不影响服务 */ }
77
+ }
78
+ };
52
79
  const streamBuf = new Map();
53
80
  const rawBuf = new Map();
54
81
  const cmdCount = new Map();
@@ -56,6 +83,12 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
56
83
  const seqHold = new Set();
57
84
  /** server 侧各终端最近一次的对外 busy 状态(仅用于日志:翻转时记一行) */
58
85
  const busyState = new Map();
86
+ /**
87
+ * 各 key 是否已处于"无 agent 连接且命令未完成"的 fallback 状态(仅用于日志:**边沿触发**)。
88
+ * buildSessions 由状态事件驱动(及低频兜底)被调用,故用 per-key 上一轮状态做**幂等**判定:
89
+ * 仅在进入该状态(首转 fallback)与离开该状态(agent 重连 / 命令收尾)各记一行,稳定期间不记。
90
+ */
91
+ const fallbackState = new Set();
59
92
  /**
60
93
  * 记录 server 侧 busy 翻转(仅在值变化时输出一行,带 session/终端/原因):
61
94
  * 与终端侧 [busy] 行互补,便于对齐"server 认为忙/闲"的时间线。
@@ -80,7 +113,8 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
80
113
  }
81
114
  if (path === "/") {
82
115
  const html = renderPage(lang, pageTemplate);
83
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
116
+ // 不缓存:页面内联的 __I18N__ 由本进程注入,若被浏览器缓存会在 server 重启后与磁盘上的 app.js 版本错配
117
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
84
118
  res.end(html);
85
119
  return;
86
120
  }
@@ -108,6 +142,91 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
108
142
  const subscribersOf = (sid, name) => {
109
143
  return [...wss.clients].filter((c) => c.readyState === WebSocket.OPEN && c._sub?.sid === sid && c._sub?.name === name);
110
144
  };
145
+ /**
146
+ * 收到客户端应用层 pong:清除"等待应答"标记与超时定时器(心跳闭环)。
147
+ * @param ws 收到 pong 的连接
148
+ */
149
+ const markPong = (ws) => {
150
+ ws._awaitPong = false;
151
+ if (ws._pongTimer) {
152
+ clearTimeout(ws._pongTimer);
153
+ ws._pongTimer = undefined;
154
+ }
155
+ };
156
+ /** 所有已连 agent 认领的会话键并集(多实例:任一实例认领即视为有主) */
157
+ const claimedByAnyAgent = () => {
158
+ const claimed = new Set();
159
+ for (const set of agents.values())
160
+ for (const k of set)
161
+ claimed.add(k);
162
+ return claimed;
163
+ };
164
+ /**
165
+ * 清理"无任何已连 agent 认领"的**磁盘状态文件**:
166
+ * 正常关闭会调 removeSessionState 删文件,残留即属主进程异常退出(或进程退出兜底未清理)留下的孤儿;
167
+ * 其 connected=true/busy=true 不会自愈——旧实现只在 2s 轮询里读,故会长期误报"在线/忙",
168
+ * 还会让 server-entry 的"无连接空闲自动关闭"永不触发。只删状态文件,**不动 history 目录**(离线历史仍可查看)。
169
+ * 保守优先:
170
+ * - **仅固定端口(port>0)服务执行**:port=0 时每实例各起一个服务(默认 config),本服务的"未认领"无法
171
+ * 区分孤儿与别的实例正在使用的活会话;且状态文件 updatedAt 不随空闲刷新,"陈旧"判据同样区分不了
172
+ * → 直接跳过,宁可不清理也不删活数据(内存运行态回收在 cleanupOrphanRuntime 内照常执行,不受影响);
173
+ * - 无 agent 代表(agents.size===0)时跳过——没有可信的"当前认领"视图,不判断;
174
+ * - 服务启动不足 ORPHAN_STATE_GRACE_MS 时跳过——给存量 agent 重连、注册的窗口,防误删活进程文件;
175
+ * - 仅清理 updatedAt 早于 ORPHAN_STATE_MIN_AGE_MS 的文件——避免误删刚写入、agent 尚未注册的文件。
176
+ * @param claimed 所有已连 agent 认领的会话键并集
177
+ */
178
+ const sweepOrphanStateFiles = (claimed) => {
179
+ if (!isFixedPortServer)
180
+ return;
181
+ if (agents.size === 0)
182
+ return;
183
+ if (Date.now() - serverStartedAt < ORPHAN_STATE_GRACE_MS)
184
+ return;
185
+ const now = Date.now();
186
+ const stale = listAllSessions(dir).filter((s) => {
187
+ if (claimed.has(sessionKey(s.sessionID, s.name)))
188
+ return false;
189
+ return now - (s.updatedAt ?? 0) > ORPHAN_STATE_MIN_AGE_MS;
190
+ });
191
+ if (!stale.length)
192
+ return;
193
+ for (const s of stale)
194
+ removeSessionState(dir, s.sessionID, s.name);
195
+ infoDedup(`orphan-state:${stale.map((s) => sessionKey(s.sessionID, s.name)).sort().join(",")}`, `清理无 agent 认领的孤儿状态文件 ${stale.length} 个: ${stale.map((s) => keyTag(sessionKey(s.sessionID, s.name))).join(", ")}`);
196
+ };
197
+ /**
198
+ * 清理"无任何已连 agent 认领"的终端运行态(僵尸条目):
199
+ * agent 断开后其运行态(streamBuf 的 done=false / rawBuf / 排队保持等)若无人接管会永久滞留,
200
+ * 导致 busy 恒为 ⏳、[fallback] 长期存在、离线终端仍被当成"运行中"。
201
+ * 多实例安全:只回收**所有已连 agent 都没认领**的 key——任一实例注册时都可安全回收孤儿;
202
+ * 仍被本实例或其它实例认领的条目一律不动。history 落盘历史**不删**(仅供离线查看)。
203
+ * 同时(同一注册时机)清理无人认领的**状态文件**(见 sweepOrphanStateFiles),不留孤儿文件。
204
+ */
205
+ const cleanupOrphanRuntime = () => {
206
+ const claimed = claimedByAnyAgent();
207
+ const orphans = [];
208
+ for (const k of streamBuf.keys())
209
+ if (!claimed.has(k))
210
+ orphans.push(k);
211
+ for (const k of rawBuf.keys())
212
+ if (!claimed.has(k) && !orphans.includes(k))
213
+ orphans.push(k);
214
+ for (const k of seqHold)
215
+ if (!claimed.has(k) && !orphans.includes(k))
216
+ orphans.push(k);
217
+ for (const k of orphans) {
218
+ streamBuf.delete(k);
219
+ rawBuf.delete(k);
220
+ seqHold.delete(k);
221
+ cmdCount.delete(k);
222
+ busyState.delete(k);
223
+ fallbackState.delete(k);
224
+ }
225
+ if (orphans.length) {
226
+ infoDedup(`orphan-runtime:${orphans.slice().sort().join(",")}`, `清理无 agent 认领的运行态 ${orphans.length} 个: ${orphans.map(keyTag).join(", ")}`);
227
+ }
228
+ sweepOrphanStateFiles(claimed);
229
+ };
111
230
  const buildSessions = () => {
112
231
  const states = listAllSessions(dir);
113
232
  // 补:state 文件可能被清理(重启/断线),但 history 目录仍在——扫描发现离线历史终端
@@ -154,25 +273,45 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
154
273
  // 等待期间无输出,但 agent 仍在、命令未完成,busy 必须保持 true。
155
274
  // 命令真实结束只认 agent 上报的当前 seq 完成标记(cmdDone),会话拆除路径另算。
156
275
  for (const [key, b] of streamBuf) {
157
- if (b && !b.done && !agentBySession.has(key)) {
158
- infoDedup(`agent-offline:${key}`, `[fallback] ${keyTag(key)} 无 agent 连接:命令状态未知(保持未完成,不判完成/不发 diff done)command=${JSON.stringify((b.command ?? "").slice(0, 120))} ts=${b.ts}`);
276
+ const inFallback = !!b && !b.done && !agentBySession.has(key);
277
+ if (inFallback && !fallbackState.has(key)) {
278
+ fallbackState.add(key);
279
+ // 边沿触发:仅在首次转入"无 agent 且未完成"时记一行(不含命令内容,防敏感载荷落盘)
280
+ log.info(`[fallback] ${keyTag(key)} 无 agent 连接:命令状态未知(保持未完成,不判完成/不发 diff done)ts=${b.ts}`);
281
+ }
282
+ else if (!inFallback && fallbackState.has(key)) {
283
+ fallbackState.delete(key);
284
+ // 恢复:agent 重连(重新注册并补播 cmdStart)或命令被收尾,各记一行后回到稳定态不再记
285
+ const reason = agentBySession.has(key) ? "agent 重连" : b?.done ? "命令已收尾" : "状态已恢复";
286
+ log.info(`[fallback] ${keyTag(key)} 恢复:${reason}(退出"无 agent 且未完成"状态)`);
159
287
  }
160
288
  if (!agentBySession.has(key))
161
289
  seqHold.delete(key); // agent 已消失:清排队序列 busy 保持
162
290
  liveBusy.set(key, (!!b && !b.done) || seqHold.has(key));
163
291
  }
292
+ // 清理已从 streamBuf 移除的 key(删除终端 / 缓冲回收)的 fallback 状态,避免同名 key 复用时漏记首次
293
+ for (const k of fallbackState)
294
+ if (!streamBuf.has(k))
295
+ fallbackState.delete(k);
164
296
  for (const s of states) {
165
- if (s.connected && !liveKey.has(sessionKey(s.sessionID, s.name))) {
297
+ const lk = sessionKey(s.sessionID, s.name);
298
+ const online = liveKey.has(lk);
299
+ if (s.connected && !online) {
166
300
  s.connected = false;
167
301
  }
168
- const lk = sessionKey(s.sessionID, s.name);
169
302
  const busy = liveBusy.get(lk);
170
303
  if (busy !== undefined)
171
304
  s.busy = busy;
172
- // 盲点显式化:拿不到 agent/实时流状态时,busy 回退取状态文件(可能是命令执行前写的旧值,未必可靠)
305
+ // 无 agent 连接 = 终端离线:busy 归零,不回退状态文件(进程异常退出时那里可能残留 busy=true,
306
+ // 会让离线终端一直显示 ⏳)。离线用既有 ○ 表达,不再叠加"忙"。
307
+ else if (!online) {
308
+ s.busy = false;
309
+ logBusy(lk, false, "无 agent 连接(离线)");
310
+ }
311
+ // 盲点显式化:agent 在线但拿不到实时流状态时,busy 回退取状态文件(可能是命令执行前写的旧值,未必可靠)
173
312
  else
174
313
  logBusy(lk, !!s.busy, "状态文件回退(无 agent 状态)");
175
- seen.add(sessionKey(s.sessionID, s.name));
314
+ seen.add(lk);
176
315
  }
177
316
  const bySession = new Map();
178
317
  for (const s of states) {
@@ -253,6 +392,49 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
253
392
  const sep = key.indexOf(":");
254
393
  return [key.slice(0, sep), key.slice(sep + 1)];
255
394
  };
395
+ /**
396
+ * 注册时的运行态对账(修复"断线丢 cmdDone → server 内存僵尸 busy"):
397
+ * agent 注册时上报"本进程认领的全部终端"(register.sessions)与"其中真正在运行命令的子集"(register.running)。
398
+ * 对**本 agent 认领、但不在运行子集内**的终端,把 server 仍标记 done=false 的残留流收尾
399
+ * (置 done、广播 diff done、busy 归 false)——这正是"命令在 agent 断线期间完成、cmdDone 发往已死 socket 而丢失"的僵尸。
400
+ * 只在对账可确定时收尾:register 未携带 running(旧 agent / agent 自身无法确定)时**不臆测**,
401
+ * 仅记一行"未能对账"后保持现状(宁可保留僵尸,也不误判在线命令已完成)。
402
+ * @param claimKeys 本 agent 本次认领的会话键集合
403
+ * @param runningRaw register 消息里的 running 字段(未知类型,需运行时校验)
404
+ */
405
+ const reconcileAgentRuntime = (claimKeys, runningRaw) => {
406
+ if (!Array.isArray(runningRaw)) {
407
+ log.info(`[reconcile] agent 注册未携带运行集合(未能对账),保持现状不臆测(认领 ${claimKeys.size} 个终端)`);
408
+ return;
409
+ }
410
+ const running = new Set();
411
+ for (const r of runningRaw) {
412
+ if (r && typeof r.sessionID === "string" && typeof r.name === "string")
413
+ running.add(sessionKey(r.sessionID, r.name));
414
+ }
415
+ let finalized = 0;
416
+ for (const k of claimKeys) {
417
+ if (running.has(k))
418
+ continue; // 确在运行:保留 busy(agent 紧随其后的 cmdStart 会补播命令文本)
419
+ const b = streamBuf.get(k);
420
+ if (b && !b.done) {
421
+ b.done = true;
422
+ b.endTs = b.endTs ?? Date.now();
423
+ logBusy(k, false, "register 对账(cmdDone 丢失)");
424
+ finalizeCommand(k);
425
+ finalized++;
426
+ continue;
427
+ }
428
+ if (seqHold.has(k)) {
429
+ // 排队保持也一并放开:其最后一条 cmdDone 同样可能随断线丢失,否则 busy 仍会被 hold 住
430
+ seqHold.delete(k);
431
+ logBusy(k, false, "register 对账(队列保持释放)");
432
+ finalized++;
433
+ }
434
+ }
435
+ if (finalized)
436
+ log.info(`[reconcile] agent 注册对账收尾 ${finalized} 个终端(认领但未运行,cmdDone 丢失)`);
437
+ };
256
438
  /** 广播 sessions(json 变化去重;busy 翻转必然变化) */
257
439
  const pushSessions = () => {
258
440
  const payload = buildSessions();
@@ -266,7 +448,57 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
266
448
  send(client, { type: "sessions", ...payload });
267
449
  }
268
450
  };
269
- const statusTimer = setInterval(() => { pushSessions(); }, STATUS_PUSH_MS);
451
+ /** 状态推送合并定时器(事件驱动的去抖合并;null=空闲无待推) */
452
+ let statusPushTimer = null;
453
+ /**
454
+ * 请求一次状态推送(去抖合并):所有**状态变化事件**(cmdStart/cmdDone/exec/busy 翻转/连接变化/会话增删/客户端拉取)
455
+ * 调用它,窗口内多次只推一次全量最新状态。不变化时不推(pushSessions 内按 json 逐客户端去重)。
456
+ */
457
+ const requestStatusPush = () => {
458
+ if (statusPushTimer !== null)
459
+ return;
460
+ statusPushTimer = setTimeout(() => {
461
+ statusPushTimer = null;
462
+ pushSessions();
463
+ }, STATUS_PUSH_DEBOUNCE_MS);
464
+ };
465
+ // 兜底:事件驱动已覆盖服务端内存态的全部变化;此低频周期只兜底**带外变化**(其他进程写入状态文件
466
+ // 导致的新终端 / pending / 标题变化等,服务端无对应事件),保证这类变化最长 STATUS_PUSH_FALLBACK_MS 内可达前端。
467
+ const statusFallbackTimer = setInterval(() => { pushSessions(); }, STATUS_PUSH_FALLBACK_MS);
468
+ /**
469
+ * 应用层心跳(server 侧):每隔 HEARTBEAT_INTERVAL_MS 向每条连接下发 {type:"ping"},
470
+ * 并在 HEARTBEAT_TIMEOUT_MS 内未收到 pong 时 terminate。server↔agent、server↔browser 两条 WS 共用:
471
+ * terminate 后对端 close 事件触发既有自动重连,把"半开连接"变成可发现、可自愈。
472
+ * 心跳只在 WS 通道收发,不写 PTY/终端。
473
+ */
474
+ const heartbeatTimer = setInterval(() => {
475
+ for (const ws of wss.clients) {
476
+ if (ws.readyState !== WebSocket.OPEN)
477
+ continue;
478
+ if (ws._awaitPong) {
479
+ // 上一条 ping 既没等到 pong、超时定时器也未生效(兜底路径):判定半开,直接断开
480
+ log.info(`[heartbeat] 心跳无应答(兜底)断开连接 agent=${!!agents.get(ws)}`);
481
+ try {
482
+ ws.terminate();
483
+ }
484
+ catch { /* 已关闭 */ }
485
+ continue;
486
+ }
487
+ ws._awaitPong = true;
488
+ send(ws, { type: "ping" });
489
+ ws._pongTimer = setTimeout(() => {
490
+ if (!ws._awaitPong)
491
+ return;
492
+ log.info(`[heartbeat] 心跳超时 ${HEARTBEAT_TIMEOUT_MS}ms 无 pong,断开连接 agent=${!!agents.get(ws)}`);
493
+ try {
494
+ ws.terminate();
495
+ }
496
+ catch { /* 已关闭 */ }
497
+ }, HEARTBEAT_TIMEOUT_MS);
498
+ if (typeof ws._pongTimer.unref === "function")
499
+ ws._pongTimer.unref();
500
+ }
501
+ }, HEARTBEAT_INTERVAL_MS);
270
502
  wss.on("connection", (ws) => {
271
503
  ws.on("message", (raw) => {
272
504
  let msg;
@@ -287,16 +519,23 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
287
519
  for (const old of agents.get(ws) ?? [])
288
520
  agentBySession.delete(old);
289
521
  agents.set(ws, new Set());
522
+ const claimKeys = new Set();
290
523
  for (const s of sessions) {
291
524
  const k = sessionKey(s.sessionID, s.name);
292
525
  agents.get(ws).add(k);
293
526
  agentBySession.set(k, ws);
527
+ claimKeys.add(k);
294
528
  }
529
+ notifyAgentPresence();
295
530
  {
296
531
  // 记录 agent 上报的会话列表(批量实例注册同一列表时按内容去重),便于对齐 server 侧视角
297
532
  const keys = [...(agents.get(ws) ?? [])];
298
533
  infoDedup(`agent-register:${keys.slice().sort().join(",")}`, `agent 注册 ${keys.length} 个会话: ${keys.length ? keys.map(keyTag).join(", ") : "无"}`);
299
534
  }
535
+ // 运行态对账:先按本 agent 上报的"运行子集"收尾断线丢 cmdDone 的僵尸 busy,再回收无人认领的运行态与状态文件
536
+ reconcileAgentRuntime(claimKeys, msg.running);
537
+ cleanupOrphanRuntime();
538
+ requestStatusPush();
300
539
  return;
301
540
  }
302
541
  if (t === "cmdStart") {
@@ -322,7 +561,7 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
322
561
  c._txPos = 0;
323
562
  send(c, { type: "diff", sessionID: sid, name, event: "cmd", command, ts: streamBuf.get(k).ts });
324
563
  }
325
- pushSessions();
564
+ requestStatusPush();
326
565
  return;
327
566
  }
328
567
  if (t === "out") {
@@ -391,7 +630,7 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
391
630
  }
392
631
  logBusy(k, msg.more === true, msg.more === true ? "cmdDone(more 保持 busy)" : "cmdDone");
393
632
  finalizeCommand(k);
394
- pushSessions();
633
+ requestStatusPush();
395
634
  return;
396
635
  }
397
636
  if (t === "raw") {
@@ -428,6 +667,17 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
428
667
  return;
429
668
  }
430
669
  // ===== web 请求 =====
670
+ if (t === "list") {
671
+ // web 连接/重连后主动拉取一次会话列表(原依赖 2s 轮询被动收;改事件驱动后必须显式响应,
672
+ // 否则新开标签页要等兜底周期才有内容)。只回本连接,并按 json 去重。
673
+ const payload = buildSessions();
674
+ const json = JSON.stringify(payload);
675
+ if (json !== ws._lastSessionsJson) {
676
+ ws._lastSessionsJson = json;
677
+ send(ws, { type: "sessions", ...payload });
678
+ }
679
+ return;
680
+ }
431
681
  if (t === "subscribe") {
432
682
  const sid = typeof msg.sessionID === "string" ? msg.sessionID : "";
433
683
  const name = typeof msg.name === "string" ? msg.name : "";
@@ -501,8 +751,9 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
501
751
  ws._mode = undefined;
502
752
  return;
503
753
  }
504
- if (t === "ping") {
505
- send(ws, { type: "pong" });
754
+ // 应用层心跳应答:客户端收到 server 的 {type:"ping"} 后回 {type:"pong"},闭环即可
755
+ if (t === "pong") {
756
+ markPong(ws);
506
757
  return;
507
758
  }
508
759
  if (t === "exec") {
@@ -521,7 +772,7 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
521
772
  const existing = streamBuf.get(k);
522
773
  streamBuf.set(k, { data: existing && existing.done ? "" : existing?.data ?? "", done: false, ts: Date.now(), command });
523
774
  logBusy(k, true, "exec");
524
- pushSessions();
775
+ requestStatusPush();
525
776
  send(agent, { type: "run-exec", reqId: `${sid}:${name}:${Date.now()}`, sessionID: sid, name, command });
526
777
  return;
527
778
  }
@@ -541,9 +792,17 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
541
792
  const name = typeof msg.name === "string" ? msg.name : "";
542
793
  if (!sid || !name)
543
794
  return;
544
- const agent = agentBySession.get(sessionKey(sid, name));
545
- if (agent)
546
- send(agent, { type: "run-delete", sessionID: sid, name });
795
+ const k = sessionKey(sid, name);
796
+ const agent = agentBySession.get(k);
797
+ // 归属校验:仅当本服务**已被该 key 的 agent 认领**(agentBySession 内有此 key,即 agents 集合含它)
798
+ // 时才允许删盘上状态文件与 history 目录。port:0 多实例下每实例各起一个服务,未认领的 key 极可能
799
+ // 属于**别的 opencode 实例正在使用的活会话**,无条件删会 rmSync 掉别人的历史目录
800
+ // (本模块唯一真·数据丢失路径)→ 未认领一律拒绝删除并留一行日志便于排查。
801
+ if (!agent) {
802
+ log.info(`Web 页删除终端 ${sid}/${name} 被拒:本服务未认领该终端(无 agent 归属),跳过删除以免误删他实例数据`);
803
+ return;
804
+ }
805
+ send(agent, { type: "run-delete", sessionID: sid, name });
547
806
  removeSessionState(dir, sid, name);
548
807
  // 删除该终端的 history 目录:buildSessions 会按 history 目录兜底列出离线历史终端,
549
808
  // 只删 state 文件不清目录会导致"删除后页面仍在"(残留被兜底扫描重新发现)
@@ -556,11 +815,15 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
556
815
  rawBuf.delete(sessionKey(sid, name));
557
816
  busyState.delete(sessionKey(sid, name));
558
817
  log.info(`Web 页删除终端 ${sid}/${name}`);
559
- pushSessions();
818
+ requestStatusPush();
560
819
  return;
561
820
  }
562
821
  });
563
822
  ws.on("close", () => {
823
+ if (ws._pongTimer) {
824
+ clearTimeout(ws._pongTimer);
825
+ ws._pongTimer = undefined;
826
+ }
564
827
  const reg = agents.get(ws);
565
828
  if (reg) {
566
829
  // 盲点显式化:agent 断开时记录受影响的终端列表(仅 agent 连接,web 订阅者不在此列)
@@ -569,6 +832,10 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
569
832
  agentBySession.delete(k);
570
833
  agents.delete(ws);
571
834
  infoDedup(`agent-disconnect:${keys.slice().sort().join(",")}`, `agent 断开(受影响终端: ${keys.length ? keys.map(keyTag).join(", ") : "无"})`);
835
+ // 连接变化是状态事件:立即推一次(原靠 2s 轮询,最坏 2s 才反映离线;改事件驱动后即时);
836
+ // 同时通知"agent 存在性"边沿(server-entry 据最后一个 agent 断开启动空闲计时)
837
+ notifyAgentPresence();
838
+ requestStatusPush();
572
839
  }
573
840
  });
574
841
  });
@@ -582,7 +849,24 @@ export function startServer(port, _getSessions, dir, lang = "en", _streamTickMs
582
849
  server,
583
850
  port: actualPort,
584
851
  url: `http://127.0.0.1:${actualPort}`,
585
- close: () => { clearInterval(statusTimer); server.close(); wss.close(); },
852
+ close: () => {
853
+ if (statusPushTimer) {
854
+ clearTimeout(statusPushTimer);
855
+ statusPushTimer = null;
856
+ }
857
+ clearInterval(statusFallbackTimer);
858
+ clearInterval(heartbeatTimer);
859
+ server.close();
860
+ wss.close();
861
+ },
862
+ onAgentPresence: (cb) => {
863
+ presenceListeners.push(cb);
864
+ // 立即回报一次当前状态:订阅方(server-entry)据此决定是否已空闲(无 agent)
865
+ try {
866
+ cb(agents.size > 0);
867
+ }
868
+ catch { /* 订阅回调异常不影响服务 */ }
869
+ },
586
870
  });
587
871
  });
588
872
  });
@@ -600,6 +884,8 @@ function renderPage(lang, template) {
600
884
  out = out.replaceAll("__I18N_JSON__", JSON.stringify(i18nObj));
601
885
  out = out.replaceAll("__PTY_COLS_VAL__", String(PTY_COLS));
602
886
  out = out.replaceAll("__PTY_ROWS_VAL__", String(PTY_ROWS));
887
+ out = out.replaceAll("__HEARTBEAT_INTERVAL_MS_VAL__", String(HEARTBEAT_INTERVAL_MS));
888
+ out = out.replaceAll("__HEARTBEAT_CLIENT_TIMEOUT_MS_VAL__", String(HEARTBEAT_CLIENT_TIMEOUT_MS));
603
889
  return out;
604
890
  }
605
891
  function readTemplate(webDir) {
@@ -638,7 +924,8 @@ function serveStatic(res, webDir, rel) {
638
924
  return;
639
925
  }
640
926
  const ext = extname(file).toLowerCase();
641
- res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream" });
927
+ // 不缓存:静态资源(app.js/app.css)无哈希文件名,缓存会与页面注入的 __I18N__ 版本错配
928
+ res.writeHead(200, { "Content-Type": MIME[ext] ?? "application/octet-stream", "Cache-Control": "no-store" });
642
929
  res.end(readFileSync(file));
643
930
  }
644
931
  function readHistoryFromFile(dir, sessionID, name) {