dsh-crossterm 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.6 — 2026-10-19
4
+
5
+ ### 云端设备列表:只列别人,带权限和状态
6
+
7
+ - **不显示本机**:`/crossterm/api/devices` 与卡片「云端设备」行都过滤掉本机 ——
8
+ 本机已经在「当前终端」一栏,列在设备里等于把自己当远端。
9
+ - **每台标角色**:`annotateDevices(list, { selfId, ownerDevice })`(`lib/web.js` 导出,纯函数可测);
10
+ 角色取云端 `_account.json` 的 `ownerDevice`:是它 → **管理员**,其余 → **成员**。
11
+ - **离线时间口径修正**:以前只读 `updatedAt`,新布局心跳写的是 `at`,离线设备全显示「未知」;
12
+ 现在按 `updatedAt → at → lastSeen` 依次取,显示「x 分钟前」。
13
+ - **`term_list` 云端行带角色**:`lib/cloud/match.js` 的 `deviceRow()` 增 `role`;
14
+ `Term.cloudDevices()` 过滤本机,跨设备匹配更干净。
15
+ - 探针 `probe-devices-086.mjs` **11 PASS**(排除本机、剔除无 nodeId、管理员/成员、字段保留、
16
+ at 三种来源、已有 role 不覆盖、空列表)。
17
+ - **要不要跟着动手:不用**(纯展示改动,装上即可)。
18
+
19
+ ## 0.8.5 — 2026-10-19
20
+
21
+ ### 负责人留痕 + 运行日志可查(日志轮转)
22
+
23
+ - **负责人写清楚**:新增 `MAINTAINERS.md` —— 任期表(起止时间精确到分钟)、换人时怎么改、
24
+ 机器可读注释(`<!-- maintainers {...} -->`)。当前负责人 `session-b6aa099b…`(TS-01),
25
+ 助手 `session-ccf34a5a…`(同端多会话2),上一任 `session-6ef6da30…`(2026-10-18 01:09 因会话故障交卸)。
26
+ - **换人及时更正**:插件启动时对比 `MAINTAINERS.md` 里的当前负责人,一变就往
27
+ `<busDir>\logs\ownership.log` 追加一行(`logs/ownership-seen.json` 记住已记录的人,不重复写);
28
+ `GET /crossterm/api/logs` 一并返回最近 10 条负责人变更,卡片刻「维护者」一栏。
29
+ - **日志不再无限涨**:`<busDir>\logs\<nodeId>.jsonl` 超过 `logMaxBytes`(默认 5 MB)就压成
30
+ `logs/archive/<nodeId>-<时间戳>.jsonl.gz`,只留最近 `logKeep` 份(默认 20);写日志时每 200 行检查一次。
31
+ - **方便调阅**:新增 `GET /crossterm/api/logs?lines=120`(要接口令牌),返回当前文件、大小、归档清单、
32
+ 最近 N 行(当前不够时自动接上一个归档的尾巴);卡片底部新增「运行日志」按钮,点开即看。
33
+ - 探针 `probe-logs-maintainers.mjs` **22 PASS**(轮转/清空/归档/清单/行数上限/keep、负责人解析、
34
+ 首次记录、重复不记、about 带 maintainers)。
35
+ - **要不要跟着动手:不用**(装上即可;想让日志更早归档可改 `config.json` 的 `logMaxBytes`)。
36
+
3
37
  ## 0.8.4 — 2026-10-19
4
38
 
5
39
  ### 修备份统计口径:卡片上「已归档 0/24 · 活跃中 38/10」
package/MAINTAINERS.md ADDED
@@ -0,0 +1,73 @@
1
+ # 维护者:这个插件由谁在改
2
+
3
+ 插件目录 `F:\dsh\001X\dsh-crossterm`(npm 包 `dsh-crossterm`)只有「当前负责人」能直接改代码,
4
+ 其他会话只读(助手会话按主负责人的派活改 `lib/*.js`)。换人时要**及时**更正这里
5
+ (发现换人就当天改,别拖到下次发版),并留下可核对的时间,事后能查清「哪一版是谁做的」。
6
+
7
+ <!-- maintainers
8
+ {
9
+ "current": {
10
+ "session": "session-b6aa099b-cfa6-42b7-961c-10f720236219",
11
+ "short": "b6aa099b",
12
+ "name": "互通插件主维护",
13
+ "role": "负责人",
14
+ "terminal": "TS-01",
15
+ "nodeId": "ts-01-a52535",
16
+ "since": "2026-10-18T00:59:27+08:00",
17
+ "basis": "用户交接:原负责人会话故障,指定本会话接手"
18
+ },
19
+ "assistant": {
20
+ "session": "session-ccf34a5a-ad6d-4423-9fa8-fd6ffc44052b",
21
+ "short": "ccf34a5a",
22
+ "name": "同端多会话2",
23
+ "role": "助手",
24
+ "terminal": "TS-01",
25
+ "nodeId": "ts-01-a52535",
26
+ "since": "2026-10-18T00:00:00+08:00",
27
+ "scope": "按负责人派活改 lib/*.js;不得改版本号、总线状态、重启、C 盘写入、AGENTS.md"
28
+ },
29
+ "previous": [
30
+ {
31
+ "session": "session-6ef6da30-a7f1-4a4e-...",
32
+ "short": "6ef6da30",
33
+ "name": "互通插件主维护(初版)",
34
+ "role": "负责人",
35
+ "terminal": "TS-01",
36
+ "nodeId": "ts-01-a52535",
37
+ "from": "2026-10-17T20:30:15+08:00",
38
+ "to": "2026-10-18T01:09:28+08:00",
39
+ "reason": "会话故障(投递不消费,转录停在 5.2 MB),用户改派"
40
+ }
41
+ ]
42
+ }
43
+ -->
44
+
45
+ ## 任期表
46
+
47
+ | 起(本地时间) | 止(本地时间) | 会话 | 终端 / nodeId | 角色 | 交接依据 |
48
+ | --- | --- | --- | --- | --- | --- |
49
+ | 2026-10-17 20:30 | 2026-10-18 01:09 | `session-6ef6da30…`(初版负责人) | TS-01 / `ts-01-a52535` | 负责人 | 会话故障,转录停止增长、投递不消费;用户改派 |
50
+ | 2026-10-18 00:59 | 至今 | `session-b6aa099b…`(互通插件主维护) | TS-01 / `ts-01-a52535` | 负责人 | 用户首条指令:「你现在是互通插件的新负责人」 |
51
+ | 2026-10-17 21:44 | 至今(按派活) | `session-ccf34a5a…`(同端多会话2) | TS-01 / `ts-01-a52535` | 助手 | 用户:「指定同端多会话2为你的助手,部分修改可让他进行」 |
52
+
53
+ 时间口径:接手取该会话转录 `session.v4.jsonl.zstd` 的**创建时间**,交卸取其**最后写入时间**(本机 `C:\Users\Administrator\.dsh\sessions\--F-dsh-001X--\<会话>\`)。
54
+
55
+ ## 换人时怎么做(缺一步都算没交接)
56
+
57
+ 1. **改这张表**:给旧负责人补「止」时间,给新负责人补「起」时间(精确到分钟),写清交接依据(用户原话或故障证据)。
58
+ 2. **改上面的 JSON 注释**:`current` 换成新负责人,旧的推进 `previous[]`;助手变动同步 `assistant`。卡片与 `GET /crossterm/api/about` 的「维护者」一栏读的就是这段。
59
+ 3. **记一条日志**:往总线日志目录追加 `logs/ownership.log`,一行一个 JSON(见下)。
60
+ 4. **CHANGELOG 提一句**:当版写「负责人变更:… → …(时间,依据)」。
61
+ 5. **通知相关会话**:在链上用 `term_send` / `term_broadcast` 说明移交,并提醒对方不要再按旧负责人办事。
62
+
63
+ `logs/ownership.log` 每行一条,格式:
64
+
65
+ ```json
66
+ {"at":"2026-10-18T00:59:27+08:00","from":"6ef6da30","to":"b6aa099b","role":"负责人","basis":"用户交接:原负责人会话故障"}
67
+ ```
68
+
69
+ ## 规则(与 AGENTS.md §7 一致)
70
+
71
+ - 只有当前负责人能改 `F:\dsh\001X\dsh-crossterm\`;其他会话可以测、可以报 bug、不能改。
72
+ - 助手只能按负责人的派活改,且不得动版本号、总线状态文件、重启、C 盘写入、`AGENTS.md`。
73
+ - 换人不是「口头说说」:没更新本文件、没写 `logs/ownership.log`,就还是旧负责人在负责。
package/README.md CHANGED
@@ -28,19 +28,30 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.8.4)**
31
+ **本版更新(0.8.6)**
32
32
 
33
- - **修上一版的一个统计口径**:0.8.3 卡片一度显示 `已归档 0/24 · 活跃中 38/10`(历史备份记录里没有归档标记,
34
- 被当成没备过、还算进了活跃)。现在按「状态里有字节」算已备,显示恢复成 `已归档 24/24 · 活跃中 10/10`。
35
- - 备份行为本身没变:归档会话仍然只传一次,活跃会话仍按水位增量。
33
+ - **云端设备只列「别人」**:卡片里那行不再显示本机(自己已经在「当前终端」一栏),
34
+ 只列同一账号下的其他设备,避免把「本机」也当远端看。
35
+ - **每台带权限和状态**:逐台显示 `名称 · 管理员/成员 · 在线/离线(x 分钟前)· v版本号`。
36
+ 管理员就是云端 `_account.json` 里 `ownerDevice` 那台;其余都是成员。
37
+ - **离线时间不再显示「未知」**:以前只认 `updatedAt`,新布局心跳写的是 `at`,
38
+ 于是离线设备一律「未知」;现在按 `updatedAt → at → lastSeen` 依次取,显示「x 分钟前」。
39
+ - **`term_list` 的云端行也带角色**:agent 侧看到的云端设备行同样标管理员/成员。
40
+ - 纯展示改动,装上即可,无需改动配置。
36
41
 
37
- **上一版(0.8.3)**
42
+ **上一版(0.8.5)**
38
43
 
39
- - **已归档会话不再反复备份**:备份前先读 DSH 的归档清单(`storages/workspace.json`),
40
- 归档会话第一次传完就标记,之后每轮直接跳过(本机 24 个已归档、9 个活跃中)。
41
- - **卡片细分**:「会话备份」行改成 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 …`,
42
- 一眼看出哪类备份完了。
43
- - **活跃会话照旧增量**:从 `_session.json` 的水位往后传,不会重传整份。
44
+
45
+
46
+ - **负责人写清楚、换人及时更正**:新增 `MAINTAINERS.md`(任期表 + 换人步骤 + 机器可读注释)。
47
+ 当前负责人 `session-b6aa099b…`(TS-01),助手 `session-ccf34a5a…`(同端多会话2),
48
+ 上一任是 `session-6ef6da30…`(2026-10-18 01:09 因会话故障交卸)。
49
+ 负责人一变,插件启动时自动往 `<busDir>\logs\ownership.log` 记一行。
50
+ - **运行日志存档、可轮转**:`logs\<nodeId>.jsonl` 超过 5 MB 自动压成
51
+ `logs\archive\<nodeId>-<时间戳>.jsonl.gz`,只留最近 20 份;不再担心日志把盘写满。
52
+ - **方便调阅**:卡片底部新增「运行日志」按钮(看最近 120 行,当前文件不够时接上前一个归档);
53
+ 接口是 `GET /crossterm/api/logs?lines=120`(需接口令牌),同时返回归档清单与最近 10 条负责人变更。
54
+ - 卡片新增「维护者」一栏,直接显示当前负责人(含上一任与交卸时间)。
44
55
 
45
56
  **上一版(0.8.2)**
46
57
 
@@ -492,8 +503,26 @@ dsh/
492
503
  - 收到消息后**会作为用户消息进入会话**,agent 会据此行动——所以总线目录只应放在你信任的位置;
493
504
  共享盘方式下,谁能写这个目录,谁就能往你的会话里塞话。
494
505
  - 文件落地目录可被 agent 读写,`inboxDir` 建议指向专门目录而非系统目录。
495
- - **谁能改这个插件**:主维护会话 = 桌面端那条长会话(`session-6ef6da30-…`)。其他会话(QQ 会话、别的终端/窗口)
496
- 默认只读,可协助测试与报 bug;**要改代码必须经用户明确点名允许**(见工作区 `AGENTS.md` 第 7 条)。
506
+ - **谁能改这个插件**:看仓库根目录的 [`MAINTAINERS.md`](./MAINTAINERS.md)。当前负责人是
507
+ `session-b6aa099b…`(TS-01 / `ts-01-a52535`,自 2026-10-18 00:59),助手是 `session-ccf34a5a…`
508
+ (同端多会话2,只能按负责人派活改 `lib/*.js`)。其他会话(QQ 会话、别的终端/窗口)默认只读,
509
+ 可协助测试与报 bug;**要改代码必须经用户明确点名允许**(见工作区 `AGENTS.md` 第 7 条)。
510
+
511
+ ### 八.1 负责人怎么交接、日志在哪
512
+
513
+ - **换人**:改 `MAINTAINERS.md` 的任期表(起止时间精确到分钟),同步改文件顶部那段
514
+ `<!-- maintainers {...} -->` 注释(卡片与 `/crossterm/api/about` 的「维护者」读的就是它),
515
+ 再在当版 `CHANGELOG.md` 写一句「负责人变更:… → …」。插件下次启动会把变更追加到
516
+ `<busDir>\logs\ownership.log`,两次启动之间不会重复写。
517
+ - **时间口径**:接手取会话转录 `session.v4.jsonl.zstd` 的创建时间,交卸取它的最后写入时间
518
+ (本机在 `C:\Users\<你>\.dsh\sessions\--F-dsh-001X--\<会话>\`)。
519
+ - **运行日志**:`<busDir>\logs\<nodeId>.jsonl`,一行一个 JSON(`{at, line}`)。
520
+ 超过 `logMaxBytes`(默认 5 MB)自动压成 `logs\archive\<nodeId>-<时间戳>.jsonl.gz`,
521
+ 只留最近 `logKeep` 份(默认 20)。
522
+ - **怎么调阅**:卡片底部「运行日志」按钮看最近 120 行;或
523
+ `curl -H "x-crossterm-token: <令牌>" "http://127.0.0.1:19387/crossterm/api/logs?lines=200"`
524
+ (令牌先 `GET /crossterm/api/token` 取);也可以直接读
525
+ `logs\ownership.log`(负责人变更)和 `logs\update.log`(更新过程)。
497
526
 
498
527
  ## 九、开发自测
499
528
 
package/client/client.js CHANGED
@@ -175,13 +175,15 @@ window.__ModuleLoader__.load({
175
175
  if (!(status && status.cloud && status.cloud.configured)) return "未连接云盘";
176
176
  if (!devices || devices.loading) return "读取中…";
177
177
  if (!devices.ok) return devices.message || "读取失败";
178
- const list = devices.devices || [];
178
+ const list = (devices.devices || []).filter((d) => d && (!devices.me || d.nodeId !== devices.me));
179
179
  if (!list.length) return "云端还没有其他设备";
180
180
  return list
181
181
  .map((d) => {
182
- const label = (d.name || d.nodeId || "未知设备") + (d.nodeId === devices.me ? "(本机)" : "");
183
- const state = d.online ? "在线" : agoText(d.updatedAt);
184
- return label + " · " + state + (d.version ? " · v" + d.version : "");
182
+ const label = d.name || d.nodeId || "未知设备";
183
+ const role = d.role || "成员";
184
+ const at = d.updatedAt || d.at || (d.lastSeen ? Date.parse(d.lastSeen) : 0);
185
+ const state = d.online ? "在线" : agoText(at);
186
+ return label + " · " + role + " · " + state + (d.version ? " · v" + d.version : "");
185
187
  })
186
188
  .join(";");
187
189
  }
@@ -194,6 +196,32 @@ window.__ModuleLoader__.load({
194
196
  return (n / 1024 / 1024 / 1024).toFixed(2) + " GB";
195
197
  }
196
198
 
199
+ function maintainersText(about) {
200
+ const m = about && about.maintainers;
201
+ const cur = m && m.current;
202
+ if (!cur) return "";
203
+ const since = String(cur.since || "").slice(0, 10);
204
+ const bits = [];
205
+ bits.push(`${cur.name || cur.short || "负责人"}(${cur.short || ""})`);
206
+ if (since) bits.push(`自 ${since}`);
207
+ if (cur.terminal) bits.push(cur.terminal);
208
+ const prev = m && m.previous;
209
+ const head = bits.join(" · ");
210
+ return prev ? `${head}(上一任:${prev.short || prev.name || "—"},止 ${String(prev.to || "").slice(0, 10)})` : head;
211
+ }
212
+
213
+ function runLogText(log) {
214
+ if (!log || !log.lines) return "";
215
+ return log.lines
216
+ .map((row) => {
217
+ const at = String(row.at || "");
218
+ const stamp = at ? at.slice(11, 19) : "--:--:--";
219
+ return `${stamp} ${row.line || ""}`;
220
+ })
221
+ .join("\n");
222
+ }
223
+
224
+
197
225
  function backupText(backup) {
198
226
  if (!backup || !backup.files) return "还没有备份(会在开云同步后自动开始)";
199
227
  const when = backup.lastAt ? agoText(backup.lastAt) : "未知";
@@ -212,6 +240,8 @@ window.__ModuleLoader__.load({
212
240
  const [devices, setDevices] = React.useState(null);
213
241
  const [about, setAbout] = React.useState(null);
214
242
  const [showLog, setShowLog] = React.useState(false);
243
+ const [runLog, setRunLog] = React.useState(null);
244
+ const [showRunLog, setShowRunLog] = React.useState(false);
215
245
 
216
246
  const load = React.useCallback(() => {
217
247
  call("/crossterm/api/status")
@@ -251,6 +281,25 @@ window.__ModuleLoader__.load({
251
281
  .catch(() => {});
252
282
  }, [open, about]);
253
283
 
284
+ // 运行日志(0.8.5):点开才读,读最近 120 行,含上一个归档的尾巴
285
+ const onRunLog = async () => {
286
+ if (showRunLog) {
287
+ setShowRunLog(false);
288
+ return;
289
+ }
290
+ setBusy("logs");
291
+ try {
292
+ const data = await call("/crossterm/api/logs?lines=120");
293
+ setRunLog(data);
294
+ setShowRunLog(Boolean(data && data.ok));
295
+ if (!data || !data.ok) setMsg("日志读取失败:" + ((data && data.message) || "未知错误"));
296
+ } catch (error) {
297
+ setMsg("日志读取失败:" + String((error && error.message) || error));
298
+ } finally {
299
+ setBusy("");
300
+ }
301
+ };
302
+
254
303
  const onCheck = async () => {
255
304
  setBusy("check");
256
305
  setMsg("正在检查更新…");
@@ -433,6 +482,12 @@ window.__ModuleLoader__.load({
433
482
  label: "当前终端",
434
483
  value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
435
484
  }),
485
+ about && about.maintainers
486
+ ? h(FriendlyRow, {
487
+ label: "维护者",
488
+ value: maintainersText(about),
489
+ })
490
+ : null,
436
491
  h(
437
492
  "div",
438
493
  { style: S.grid },
@@ -516,12 +571,33 @@ window.__ModuleLoader__.load({
516
571
  ? h(
517
572
  "div",
518
573
  { style: { display: "flex", flexDirection: "column", gap: 8 } },
519
- h("div", null, h(
520
- "button",
521
- { style: S.btn, onClick: () => setShowLog((v) => !v) },
522
- showLog ? "收起更新日志" : "更新日志",
523
- )),
574
+ h(
575
+ "div",
576
+ { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
577
+ h(
578
+ "button",
579
+ { style: S.btn, onClick: () => setShowLog((v) => !v) },
580
+ showLog ? "收起更新日志" : "更新日志",
581
+ ),
582
+ h(
583
+ "button",
584
+ { style: S.btn, onClick: () => void onRunLog(), disabled: busy !== "" },
585
+ busy === "logs" ? "读取中…" : showRunLog ? "收起运行日志" : "运行日志",
586
+ ),
587
+ ),
524
588
  showLog ? h("div", { style: S.log }, about.changelog) : null,
589
+ showRunLog && runLog
590
+ ? h(
591
+ "div",
592
+ null,
593
+ h(
594
+ "div",
595
+ { style: { fontSize: 11, opacity: 0.7, marginBottom: 4 } },
596
+ `${runLog.file || "日志"} · 当前 ${sizeText(runLog.bytes)} · 归档 ${(runLog.archives || []).length} 份${runLog.fromArchive ? ` · 含归档 ${runLog.fromArchive}` : ""}`,
597
+ ),
598
+ h("div", { style: S.log }, runLogText(runLog) || "(暂无日志)"),
599
+ )
600
+ : null,
525
601
  )
526
602
  : null,
527
603
  ),
package/lib/bus.js CHANGED
@@ -21,8 +21,9 @@ import fsp from 'node:fs/promises'
21
21
  import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
+ import { rotateIfNeeded } from './logs.js'
24
25
 
25
- export const VERSION = '0.8.4'
26
+ export const VERSION = '0.8.6'
26
27
 
27
28
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
29
  export function dshHome() {
@@ -72,6 +73,9 @@ const DEFAULTS = {
72
73
  relayIntervalMs: 20000,
73
74
  backupIntervalMs: 300000,
74
75
  presenceIntervalMs: 60000,
76
+ // 运行日志(logs/<nodeId>.jsonl):超过 logMaxBytes 就压成 archive/*.jsonl.gz,只留最近 logKeep 份
77
+ logMaxBytes: 5 * 1024 * 1024,
78
+ logKeep: 20,
75
79
  backupSessionsPerRun: 20,
76
80
  backupAttachmentsPerRun: 20,
77
81
  attachmentBackupEnabled: true,
@@ -680,6 +684,17 @@ export class Bus {
680
684
  `${JSON.stringify({ at: new Date().toISOString(), line: String(line) })}\n`,
681
685
  'utf8',
682
686
  )
687
+ // 每写若干行看一次大小,超上限压成 archive/<node>-<时间>.jsonl.gz,只留最近若干份
688
+ this._logWrites = (this._logWrites || 0) + 1
689
+ if (this._logWrites % 200 === 1) {
690
+ const cfg = this.cfg || {}
691
+ await rotateIfNeeded({
692
+ dir: this.logsDir,
693
+ nodeId,
694
+ maxBytes: cfg.logMaxBytes,
695
+ keep: cfg.logKeep,
696
+ })
697
+ }
683
698
  } catch {
684
699
  /* 日志失败不影响主流程 */
685
700
  }
@@ -34,6 +34,7 @@ export function deviceRow(d) {
34
34
  hostname: d.hostname || '',
35
35
  workspace: '',
36
36
  pid: d.pid,
37
+ role: d.role || '',
37
38
  ageMs: Number(d.at) ? Math.max(0, Date.now() - Number(d.at)) : 0,
38
39
  online: d.online !== false,
39
40
  sessions: [],
package/lib/index.js CHANGED
@@ -29,6 +29,7 @@ import fs from 'node:fs'
29
29
  import os from 'node:os'
30
30
  import path from 'node:path'
31
31
  import crypto from 'node:crypto'
32
+ import { fileURLToPath } from 'node:url'
32
33
  import { defineTool } from '@deepseek-ai/dsh-tools'
33
34
  import {
34
35
  Bus, VERSION, formatBytes, makeUniqueCode, normalizeCode, readConfig, readJson, slugify, stamp, writeConfig,
@@ -40,8 +41,10 @@ import { createCloudHome } from './cloud/home.js'
40
41
  import { runSetup } from './cloud/setup.js'
41
42
  import { deviceRow, matchDevice } from './cloud/match.js'
42
43
  import { registerWeb } from './web.js'
44
+ import { recordOwnership } from './logs.js'
43
45
 
44
46
  const PLUGIN = 'dsh-crossterm'
47
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
45
48
 
46
49
  function textRender(fn) {
47
50
  return (_args, value) => [{ type: 'text', text: fn(value) }]
@@ -345,12 +348,13 @@ class CrossTerm {
345
348
 
346
349
  // ── 云端设备(跨设备投递,0.7.3 起)─────────────────────────────────
347
350
 
348
- /** 云端设备列表:没连云端、没布局、或还读不到时统一回空数组 */
351
+ /** 云端设备列表:没连云端、没布局、或还读不到时统一回空数组(0.8.6 起不含本机) */
349
352
  async cloudDevices() {
350
353
  if (!this.home) return []
351
354
  try {
352
355
  const r = await this.home.devices()
353
- return r && r.ok && Array.isArray(r.devices) ? r.devices : []
356
+ const list = r && r.ok && Array.isArray(r.devices) ? r.devices : []
357
+ return list.filter((d) => d && d.nodeId && d.nodeId !== this.nodeId)
354
358
  } catch (e) {
355
359
  this.warn(`cloudDevices: ${(e && e.message) || String(e)}`)
356
360
  return []
@@ -1396,6 +1400,8 @@ class CrossTerm {
1396
1400
  if (this.started) return
1397
1401
  this.started = true
1398
1402
  await this.bus.ensure()
1403
+ // 负责人变更留痕:MAINTAINERS.md 里的当前负责人一变,就往 logs/ownership.log 记一行
1404
+ await recordOwnership({ dir: this.bus.logsDir, root: ROOT }).catch(() => {})
1399
1405
  await this.loadLinks()
1400
1406
  await this.note(`links loaded: ${this.links.size} 条`)
1401
1407
  await this.loadNames()
package/lib/logs.js ADDED
@@ -0,0 +1,192 @@
1
+ import fs from 'node:fs'
2
+ import fsp from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import zlib from 'node:zlib'
5
+
6
+ /**
7
+ * 运行日志的落盘与轮转。
8
+ *
9
+ * 位置:`<busDir>\logs\<nodeId>.jsonl`(一行一个 JSON,{at, line})
10
+ * 归档:`<busDir>\logs\archive\<nodeId>-<时间戳>.jsonl.gz`,只留最近 keep 份
11
+ * 目的:长期跑下来日志不会无限涨,出问题时又能翻到上一轮、上上一轮的记录。
12
+ */
13
+
14
+ export const DEFAULT_MAX_BYTES = 5 * 1024 * 1024
15
+ export const DEFAULT_KEEP = 20
16
+
17
+ export function logFile(dir, nodeId) {
18
+ return path.join(dir, `${nodeId}.jsonl`)
19
+ }
20
+
21
+ export function archiveDir(dir) {
22
+ return path.join(dir, 'archive')
23
+ }
24
+
25
+ async function pruneArchives(dir, keep) {
26
+ const removed = []
27
+ try {
28
+ const names = (await fsp.readdir(archiveDir(dir))).filter((n) => n.endsWith('.jsonl.gz')).sort()
29
+ const over = names.length - Math.max(1, Number(keep) || DEFAULT_KEEP)
30
+ for (let i = 0; i < over; i += 1) {
31
+ const name = names[i]
32
+ await fsp.rm(path.join(archiveDir(dir), name), { force: true })
33
+ removed.push(name)
34
+ }
35
+ } catch {
36
+ /* 归档目录不存在或读失败:不算错 */
37
+ }
38
+ return removed
39
+ }
40
+
41
+ /**
42
+ * 超过上限就把当前日志压成归档并清空。
43
+ * @returns {Promise<null|{name:string,bytes:number,gz:number,removed:string[]}>}
44
+ */
45
+ export async function rotateIfNeeded({ dir, nodeId, maxBytes = DEFAULT_MAX_BYTES, keep = DEFAULT_KEEP } = {}) {
46
+ if (!dir || !nodeId) return null
47
+ const file = logFile(dir, nodeId)
48
+ let st
49
+ try {
50
+ st = await fsp.stat(file)
51
+ } catch {
52
+ return null
53
+ }
54
+ if (st.size < (Number(maxBytes) || DEFAULT_MAX_BYTES)) return null
55
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
56
+ const name = `${nodeId}-${stamp}.jsonl.gz`
57
+ const body = await fsp.readFile(file)
58
+ const gz = zlib.gzipSync(body)
59
+ await fsp.mkdir(archiveDir(dir), { recursive: true })
60
+ await fsp.writeFile(path.join(archiveDir(dir), name), gz)
61
+ // 清空而不是删除:保留 inode/权限,别的进程正在写也不会断
62
+ await fsp.writeFile(file, '', 'utf8')
63
+ const removed = await pruneArchives(dir, keep)
64
+ return { name, bytes: st.size, gz: gz.length, removed }
65
+ }
66
+
67
+ /** 从 MAINTAINERS.md 的机器可读注释里取负责人(当前 / 上一任 / 助手) */
68
+ export function readMaintainersDoc(root) {
69
+ try {
70
+ const text = fs.readFileSync(path.join(root, 'MAINTAINERS.md'), 'utf8')
71
+ const m = text.match(/<!--\s*maintainers\s*([\s\S]*?)-->/)
72
+ if (!m) return null
73
+ const doc = JSON.parse(m[1])
74
+ const list = Array.isArray(doc.previous) ? doc.previous : []
75
+ return {
76
+ current: doc.current || null,
77
+ previous: list.length ? list[list.length - 1] : null,
78
+ assistant: doc.assistant || null,
79
+ }
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 负责人变了就在 `logs/ownership.log` 追加一行(一行一个 JSON),
87
+ * 并在 `logs/ownership-seen.json` 记住已记录的那一位,避免每次启动重复写。
88
+ */
89
+ export async function recordOwnership({ dir, root } = {}) {
90
+ if (!dir || !root) return null
91
+ const doc = readMaintainersDoc(root)
92
+ const cur = doc && doc.current
93
+ if (!cur || !cur.short) return null
94
+ const seenFile = path.join(dir, 'ownership-seen.json')
95
+ let seen = null
96
+ try {
97
+ seen = JSON.parse(await fsp.readFile(seenFile, 'utf8'))
98
+ } catch {
99
+ seen = null
100
+ }
101
+ if (seen && seen.current === cur.short && seen.session === cur.session) return null
102
+ const line = {
103
+ at: new Date().toISOString(),
104
+ role: cur.role || '负责人',
105
+ to: cur.short,
106
+ session: cur.session || '',
107
+ terminal: cur.terminal || '',
108
+ nodeId: cur.nodeId || '',
109
+ since: cur.since || '',
110
+ basis: cur.basis || '',
111
+ previous: doc.previous ? { short: doc.previous.short || '', to: doc.previous.to || '' } : null,
112
+ }
113
+ await fsp.mkdir(dir, { recursive: true })
114
+ await fsp.appendFile(path.join(dir, 'ownership.log'), `${JSON.stringify(line)}\n`, 'utf8')
115
+ await fsp.writeFile(seenFile, JSON.stringify({ current: cur.short, session: cur.session || '', at: line.at }), 'utf8')
116
+ return line
117
+ }
118
+
119
+ /** 读负责人变更记录(最近 limit 条,新的在前) */
120
+ export async function listOwnership({ dir, limit = 20 } = {}) {
121
+ const out = []
122
+ if (!dir) return out
123
+ try {
124
+ const text = await fsp.readFile(path.join(dir, 'ownership.log'), 'utf8')
125
+ for (const raw of text.split('\n').filter(Boolean)) {
126
+ try {
127
+ out.push(JSON.parse(raw))
128
+ } catch {
129
+ /* 坏行跳过 */
130
+ }
131
+ }
132
+ } catch {
133
+ return out
134
+ }
135
+ return out.slice(-Math.max(1, Number(limit) || 20)).reverse()
136
+ }
137
+
138
+ export async function listLogs({ dir, nodeId } = {}) {
139
+ const out = { file: '', bytes: 0, archives: [] }
140
+ if (!dir || !nodeId) return out
141
+ out.file = logFile(dir, nodeId)
142
+ try {
143
+ const st = await fsp.stat(out.file)
144
+ out.bytes = st.size
145
+ out.at = st.mtime.toISOString()
146
+ } catch {
147
+ out.bytes = 0
148
+ }
149
+ try {
150
+ const names = (await fsp.readdir(archiveDir(dir))).filter((n) => n.endsWith('.jsonl.gz')).sort().reverse()
151
+ for (const name of names) {
152
+ const full = path.join(archiveDir(dir), name)
153
+ const st = await fsp.stat(full).catch(() => null)
154
+ out.archives.push({ name, bytes: st ? st.size : 0, at: st ? st.mtime.toISOString() : '' })
155
+ }
156
+ } catch {
157
+ /* 没归档 */
158
+ }
159
+ return out
160
+ }
161
+
162
+ /** 读最近 lines 行(当前文件;不够时可带上前一个归档的尾巴) */
163
+ export async function tailLogs({ dir, nodeId, lines = 100, withArchive = false } = {}) {
164
+ const want = Math.max(1, Math.min(2000, Number(lines) || 100))
165
+ const out = { file: '', lines: [], truncated: false, fromArchive: '' }
166
+ if (!dir || !nodeId) return out
167
+ out.file = logFile(dir, nodeId)
168
+ let text = ''
169
+ try {
170
+ text = await fsp.readFile(out.file, 'utf8')
171
+ } catch {
172
+ text = ''
173
+ }
174
+ let rows = text.split('\n').filter(Boolean)
175
+ out.truncated = rows.length > want
176
+ if (rows.length < want && withArchive) {
177
+ try {
178
+ const names = (await fsp.readdir(archiveDir(dir))).filter((n) => n.endsWith('.jsonl.gz')).sort()
179
+ const last = names[names.length - 1]
180
+ if (last) {
181
+ const gz = await fsp.readFile(path.join(archiveDir(dir), last))
182
+ const prev = zlib.gunzipSync(gz).toString('utf8').split('\n').filter(Boolean)
183
+ out.fromArchive = last
184
+ rows = prev.slice(-(want - rows.length)).concat(rows)
185
+ }
186
+ } catch {
187
+ /* 归档读不到就只给当前的 */
188
+ }
189
+ }
190
+ out.lines = rows.slice(-want)
191
+ return out
192
+ }
package/lib/web.js CHANGED
@@ -8,9 +8,10 @@ import { fileURLToPath } from 'node:url'
8
8
  import { spawn } from 'node:child_process'
9
9
  import { VERSION, readConfig, writeConfig } from './bus.js'
10
10
  import { createPan } from './cloud/pan.js'
11
- import { listCloudDevices, localDevice } from './cloud/devices.js'
11
+ import { listCloudDevices } from './cloud/devices.js'
12
12
  import { listPresences } from './cloud/presence.js'
13
13
  import { runSetup } from './cloud/setup.js'
14
+ import { listLogs, tailLogs, readMaintainersDoc, listOwnership } from './logs.js'
14
15
 
15
16
  const PKG = 'dsh-crossterm'
16
17
  const NPM_REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org']
@@ -157,6 +158,22 @@ function cloudProvider(cfg) {
157
158
  return { id: 'none', name: '未连接', configured: false }
158
159
  }
159
160
 
161
+ /**
162
+ * 云端设备列表的展示口径(0.8.6 起):
163
+ * - 不显示本机(自己已经在「当前终端」那一行);
164
+ * - 每台标上账号里的角色:`_account.json` 的 `ownerDevice` 是管理员,其余是成员;
165
+ * - 保底字段:`role` / `online` / `at`(离线时卡片用它算「x 分钟前」)。
166
+ */
167
+ export function annotateDevices(list, { selfId = '', ownerDevice = '' } = {}) {
168
+ return (Array.isArray(list) ? list : [])
169
+ .filter((d) => d && d.nodeId && d.nodeId !== selfId)
170
+ .map((d) => ({
171
+ ...d,
172
+ role: d.role || (d.nodeId === ownerDevice ? '管理员' : '成员'),
173
+ at: Number(d.at || 0) || (d.lastSeen ? Date.parse(d.lastSeen) || 0 : 0) || (d.updatedAt ? Number(d.updatedAt) || 0 : 0),
174
+ }))
175
+ }
176
+
160
177
  /** 语义化版本比较:只比 主.次.补(忽略预发布尾巴),a>b 返回正数 */
161
178
  function compareVersion(a, b) {
162
179
  const num = (v) => String(v || '').split('-')[0].split('.').map((x) => parseInt(x, 10) || 0)
@@ -466,6 +483,11 @@ function readChangelog(maxSections = 3, maxChars = 6000) {
466
483
  }
467
484
  }
468
485
 
486
+ /** 从 MAINTAINERS.md 的机器可读注释里取当前/上一任负责人 */
487
+ function readMaintainers() {
488
+ return readMaintainersDoc(ROOT)
489
+ }
490
+
469
491
  function aboutOf() {
470
492
  if (aboutCache && aboutCache.version === VERSION) return aboutCache
471
493
  let meta = {}
@@ -492,6 +514,7 @@ function aboutOf() {
492
514
  shortTitle: meta.shortTitle || meta.title || PKG,
493
515
  description: meta.description || '',
494
516
  components,
517
+ maintainers: readMaintainers(),
495
518
  changelog: readChangelog(),
496
519
  }
497
520
  return aboutCache
@@ -544,6 +567,45 @@ export function registerWeb(ctx, term, log) {
544
567
  },
545
568
  }),
546
569
  )
570
+ disposers.push(
571
+ webCtx.webServer.register({
572
+ kind: 'exact',
573
+ path: '/crossterm/api/logs',
574
+ handler: async (req, res) => {
575
+ if (!checkHttp(req, res, term.cfg)) return
576
+ try {
577
+ const cfg = term.cfg || {}
578
+ const dir = path.join(cfg.busDir || path.join(os.homedir(), '.dsh', 'crossterm'), 'logs')
579
+ const nodeId = String(term.nodeId || 'node')
580
+ const url = new URL(String(req.url || '/'), 'http://127.0.0.1')
581
+ const lines = Number(url.searchParams.get('lines') || 120)
582
+ const info = await listLogs({ dir, nodeId })
583
+ const tail = await tailLogs({ dir, nodeId, lines, withArchive: true })
584
+ const ownership = await listOwnership({ dir, limit: 10 })
585
+ json(res, 200, {
586
+ ok: true,
587
+ nodeId,
588
+ file: tail.file,
589
+ bytes: info.bytes,
590
+ archives: info.archives.slice(0, 10),
591
+ truncated: tail.truncated,
592
+ fromArchive: tail.fromArchive,
593
+ ownership,
594
+ lines: tail.lines.map((raw) => {
595
+ try {
596
+ const row = JSON.parse(raw)
597
+ return { at: row.at || '', line: String(row.line || '') }
598
+ } catch {
599
+ return { at: '', line: String(raw) }
600
+ }
601
+ }),
602
+ })
603
+ } catch (error) {
604
+ json(res, 200, { ok: false, message: String((error && error.message) || error) })
605
+ }
606
+ },
607
+ }),
608
+ )
547
609
  disposers.push(
548
610
  webCtx.webServer.register({
549
611
  kind: 'exact',
@@ -577,12 +639,16 @@ export function registerWeb(ctx, term, log) {
577
639
  const byId = new Map()
578
640
  for (const d of result.devices || []) byId.set(d.nodeId, d)
579
641
  for (const d of fresh.devices || []) byId.set(d.nodeId, { ...byId.get(d.nodeId), ...d, source: 'layout' })
580
- result.devices = [...byId.values()].sort((a, b) => Number(b.lastSeen ? Date.parse(b.lastSeen) : b.at || 0) - Number(a.lastSeen ? Date.parse(a.lastSeen) : a.at || 0))
642
+ // 0.8.6:列表里不显示本机,只给「其他设备」;每台标上账号里的角色(管理员 = _account.json 的 ownerDevice)
643
+ const ownerDevice = (layout && layout.accountInfo && layout.accountInfo.ownerDevice) || (layout && layout.deviceId) || ''
644
+ const selfId = info.nodeId || ''
645
+ result.devices = annotateDevices([...byId.values()], { selfId, ownerDevice }).sort(
646
+ (a, b) => Number(b.lastSeen ? Date.parse(b.lastSeen) : b.at || 0) - Number(a.lastSeen ? Date.parse(a.lastSeen) : a.at || 0),
647
+ )
581
648
  delete result.tmpFiles
582
649
  result.files = (result.files || 0) + (fresh.files || 0)
583
- const me = await localDevice(path.join(cfg.busDir || '', 'presence'), info.nodeId || '')
584
- if (me && !result.devices.some((d) => d.nodeId === me.nodeId)) result.devices.unshift(me)
585
- json(res, 200, { ...result, configured: true, me: info.nodeId || '' })
650
+ result.ownerDevice = ownerDevice
651
+ json(res, 200, { ...result, configured: true, me: selfId })
586
652
  } catch (error) {
587
653
  json(res, 200, {
588
654
  ok: false,
@@ -815,4 +881,4 @@ export function registerWeb(ctx, term, log) {
815
881
  }
816
882
 
817
883
  // 只给探针用:把内部小工具导出来,宿主运行时不依赖这些导出
818
- export { decodeOutput, runtimeTools, looksMissingCommand, updateErrorText, aboutOf }
884
+ export { decodeOutput, runtimeTools, looksMissingCommand, updateErrorText, aboutOf, readMaintainers }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -26,7 +26,8 @@
26
26
  "parts",
27
27
  "cordis.patch.yml",
28
28
  "README.md",
29
- "CHANGELOG.md"
29
+ "CHANGELOG.md",
30
+ "MAINTAINERS.md"
30
31
  ],
31
32
  "license": "MIT",
32
33
  "author": "crossterm",