dsh-crossterm 0.8.3 → 0.8.5

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,36 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.5 — 2026-10-19
4
+
5
+ ### 负责人留痕 + 运行日志可查(日志轮转)
6
+
7
+ - **负责人写清楚**:新增 `MAINTAINERS.md` —— 任期表(起止时间精确到分钟)、换人时怎么改、
8
+ 机器可读注释(`<!-- maintainers {...} -->`)。当前负责人 `session-b6aa099b…`(TS-01),
9
+ 助手 `session-ccf34a5a…`(同端多会话2),上一任 `session-6ef6da30…`(2026-10-18 01:09 因会话故障交卸)。
10
+ - **换人及时更正**:插件启动时对比 `MAINTAINERS.md` 里的当前负责人,一变就往
11
+ `<busDir>\logs\ownership.log` 追加一行(`logs/ownership-seen.json` 记住已记录的人,不重复写);
12
+ `GET /crossterm/api/logs` 一并返回最近 10 条负责人变更,卡片刻「维护者」一栏。
13
+ - **日志不再无限涨**:`<busDir>\logs\<nodeId>.jsonl` 超过 `logMaxBytes`(默认 5 MB)就压成
14
+ `logs/archive/<nodeId>-<时间戳>.jsonl.gz`,只留最近 `logKeep` 份(默认 20);写日志时每 200 行检查一次。
15
+ - **方便调阅**:新增 `GET /crossterm/api/logs?lines=120`(要接口令牌),返回当前文件、大小、归档清单、
16
+ 最近 N 行(当前不够时自动接上一个归档的尾巴);卡片底部新增「运行日志」按钮,点开即看。
17
+ - 探针 `probe-logs-maintainers.mjs` **22 PASS**(轮转/清空/归档/清单/行数上限/keep、负责人解析、
18
+ 首次记录、重复不记、about 带 maintainers)。
19
+ - **要不要跟着动手:不用**(装上即可;想让日志更早归档可改 `config.json` 的 `logMaxBytes`)。
20
+
21
+ ## 0.8.4 — 2026-10-19
22
+
23
+ ### 修备份统计口径:卡片上「已归档 0/24 · 活跃中 38/10」
24
+
25
+ - **症状**:0.8.3 的卡片显示 `已归档 0/24 · 活跃中 38/10` —— 归档明明全备过,活跃数还超过了总数。
26
+ - **根因**:统计用了状态里的 `archived` 标记,而升级前的历史条目(0.8.2 及更早写进 `backup-state.json` 的)
27
+ 没有这个字段 → `archivedDone` 恒为 0;`activeDone` 又是用「总条目 − archivedDone」算的,于是把归档的也算进活跃。
28
+ - **改法**:`backed(x)` 改为「状态里这一条存在且 `bytes > 0`」才算已备,不再依赖标记;
29
+ 跳过已归档会话时顺手把 `archived: true` 补进状态(以后口径不会漂);`archivedUntouched` 改由
30
+ 「归档总数 − 已备归档数」得出。
31
+ - 探针 `probe-archived-backup.mjs` **16 PASS**(含首轮 `archivedBackedUp=2 / activeBackedUp=2`、次轮全跳过)。
32
+ - **要不要跟着动手:不用**(只是计数与文案,装上即可)。
33
+
3
34
  ## 0.8.3 — 2026-10-19
4
35
 
5
36
  ### 已归档会话只备份一次,卡片把备份拆成「已归档 / 活跃中」
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,12 +28,23 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.8.3)**
32
-
33
- - **已归档会话不再反复备份**:备份前先读 DSH 的归档清单(`storages/workspace.json`),
34
- 归档会话第一次传完就标记,之后每轮直接跳过(本机 24 个已归档、9 个活跃中)。
35
- - **卡片细分**:「会话备份」行改成 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 …`,
36
- 一眼看出哪类备份完了。
31
+ **本版更新(0.8.5)**
32
+
33
+ - **负责人写清楚、换人及时更正**:新增 `MAINTAINERS.md`(任期表 + 换人步骤 + 机器可读注释)。
34
+ 当前负责人 `session-b6aa099b…`(TS-01),助手 `session-ccf34a5a…`(同端多会话2),
35
+ 上一任是 `session-6ef6da30…`(2026-10-18 01:09 因会话故障交卸)。
36
+ 负责人一变,插件启动时自动往 `<busDir>\logs\ownership.log` 记一行。
37
+ - **运行日志存档、可轮转**:`logs\<nodeId>.jsonl` 超过 5 MB 自动压成
38
+ `logs\archive\<nodeId>-<时间戳>.jsonl.gz`,只留最近 20 份;不再担心日志把盘写满。
39
+ - **方便调阅**:卡片底部新增「运行日志」按钮(看最近 120 行,当前文件不够时接上前一个归档);
40
+ 接口是 `GET /crossterm/api/logs?lines=120`(需接口令牌),同时返回归档清单与最近 10 条负责人变更。
41
+ - 卡片新增「维护者」一栏,直接显示当前负责人(含上一任与交卸时间)。
42
+
43
+ **上一版(0.8.4)**
44
+
45
+ - **修统计口径**:0.8.3 卡片一度显示 `已归档 0/24 · 活跃中 38/10`,现在按「状态里有字节」算已备,
46
+ 恢复成 `已归档 24/24 · 活跃中 10/10`。
47
+ - 备份行为本身没变:归档会话仍然只传一次,活跃会话仍按水位增量。
37
48
  - **活跃会话照旧增量**:从 `_session.json` 的水位往后传,不会重传整份。
38
49
 
39
50
  **上一版(0.8.2)**
@@ -486,8 +497,26 @@ dsh/
486
497
  - 收到消息后**会作为用户消息进入会话**,agent 会据此行动——所以总线目录只应放在你信任的位置;
487
498
  共享盘方式下,谁能写这个目录,谁就能往你的会话里塞话。
488
499
  - 文件落地目录可被 agent 读写,`inboxDir` 建议指向专门目录而非系统目录。
489
- - **谁能改这个插件**:主维护会话 = 桌面端那条长会话(`session-6ef6da30-…`)。其他会话(QQ 会话、别的终端/窗口)
490
- 默认只读,可协助测试与报 bug;**要改代码必须经用户明确点名允许**(见工作区 `AGENTS.md` 第 7 条)。
500
+ - **谁能改这个插件**:看仓库根目录的 [`MAINTAINERS.md`](./MAINTAINERS.md)。当前负责人是
501
+ `session-b6aa099b…`(TS-01 / `ts-01-a52535`,自 2026-10-18 00:59),助手是 `session-ccf34a5a…`
502
+ (同端多会话2,只能按负责人派活改 `lib/*.js`)。其他会话(QQ 会话、别的终端/窗口)默认只读,
503
+ 可协助测试与报 bug;**要改代码必须经用户明确点名允许**(见工作区 `AGENTS.md` 第 7 条)。
504
+
505
+ ### 八.1 负责人怎么交接、日志在哪
506
+
507
+ - **换人**:改 `MAINTAINERS.md` 的任期表(起止时间精确到分钟),同步改文件顶部那段
508
+ `<!-- maintainers {...} -->` 注释(卡片与 `/crossterm/api/about` 的「维护者」读的就是它),
509
+ 再在当版 `CHANGELOG.md` 写一句「负责人变更:… → …」。插件下次启动会把变更追加到
510
+ `<busDir>\logs\ownership.log`,两次启动之间不会重复写。
511
+ - **时间口径**:接手取会话转录 `session.v4.jsonl.zstd` 的创建时间,交卸取它的最后写入时间
512
+ (本机在 `C:\Users\<你>\.dsh\sessions\--F-dsh-001X--\<会话>\`)。
513
+ - **运行日志**:`<busDir>\logs\<nodeId>.jsonl`,一行一个 JSON(`{at, line}`)。
514
+ 超过 `logMaxBytes`(默认 5 MB)自动压成 `logs\archive\<nodeId>-<时间戳>.jsonl.gz`,
515
+ 只留最近 `logKeep` 份(默认 20)。
516
+ - **怎么调阅**:卡片底部「运行日志」按钮看最近 120 行;或
517
+ `curl -H "x-crossterm-token: <令牌>" "http://127.0.0.1:19387/crossterm/api/logs?lines=200"`
518
+ (令牌先 `GET /crossterm/api/token` 取);也可以直接读
519
+ `logs\ownership.log`(负责人变更)和 `logs\update.log`(更新过程)。
491
520
 
492
521
  ## 九、开发自测
493
522
 
package/client/client.js CHANGED
@@ -194,6 +194,32 @@ window.__ModuleLoader__.load({
194
194
  return (n / 1024 / 1024 / 1024).toFixed(2) + " GB";
195
195
  }
196
196
 
197
+ function maintainersText(about) {
198
+ const m = about && about.maintainers;
199
+ const cur = m && m.current;
200
+ if (!cur) return "";
201
+ const since = String(cur.since || "").slice(0, 10);
202
+ const bits = [];
203
+ bits.push(`${cur.name || cur.short || "负责人"}(${cur.short || ""})`);
204
+ if (since) bits.push(`自 ${since}`);
205
+ if (cur.terminal) bits.push(cur.terminal);
206
+ const prev = m && m.previous;
207
+ const head = bits.join(" · ");
208
+ return prev ? `${head}(上一任:${prev.short || prev.name || "—"},止 ${String(prev.to || "").slice(0, 10)})` : head;
209
+ }
210
+
211
+ function runLogText(log) {
212
+ if (!log || !log.lines) return "";
213
+ return log.lines
214
+ .map((row) => {
215
+ const at = String(row.at || "");
216
+ const stamp = at ? at.slice(11, 19) : "--:--:--";
217
+ return `${stamp} ${row.line || ""}`;
218
+ })
219
+ .join("\n");
220
+ }
221
+
222
+
197
223
  function backupText(backup) {
198
224
  if (!backup || !backup.files) return "还没有备份(会在开云同步后自动开始)";
199
225
  const when = backup.lastAt ? agoText(backup.lastAt) : "未知";
@@ -212,6 +238,8 @@ window.__ModuleLoader__.load({
212
238
  const [devices, setDevices] = React.useState(null);
213
239
  const [about, setAbout] = React.useState(null);
214
240
  const [showLog, setShowLog] = React.useState(false);
241
+ const [runLog, setRunLog] = React.useState(null);
242
+ const [showRunLog, setShowRunLog] = React.useState(false);
215
243
 
216
244
  const load = React.useCallback(() => {
217
245
  call("/crossterm/api/status")
@@ -251,6 +279,25 @@ window.__ModuleLoader__.load({
251
279
  .catch(() => {});
252
280
  }, [open, about]);
253
281
 
282
+ // 运行日志(0.8.5):点开才读,读最近 120 行,含上一个归档的尾巴
283
+ const onRunLog = async () => {
284
+ if (showRunLog) {
285
+ setShowRunLog(false);
286
+ return;
287
+ }
288
+ setBusy("logs");
289
+ try {
290
+ const data = await call("/crossterm/api/logs?lines=120");
291
+ setRunLog(data);
292
+ setShowRunLog(Boolean(data && data.ok));
293
+ if (!data || !data.ok) setMsg("日志读取失败:" + ((data && data.message) || "未知错误"));
294
+ } catch (error) {
295
+ setMsg("日志读取失败:" + String((error && error.message) || error));
296
+ } finally {
297
+ setBusy("");
298
+ }
299
+ };
300
+
254
301
  const onCheck = async () => {
255
302
  setBusy("check");
256
303
  setMsg("正在检查更新…");
@@ -433,6 +480,12 @@ window.__ModuleLoader__.load({
433
480
  label: "当前终端",
434
481
  value: status ? `${status.node}(${status.nodeId})· ${status.hostname}` : "",
435
482
  }),
483
+ about && about.maintainers
484
+ ? h(FriendlyRow, {
485
+ label: "维护者",
486
+ value: maintainersText(about),
487
+ })
488
+ : null,
436
489
  h(
437
490
  "div",
438
491
  { style: S.grid },
@@ -516,12 +569,33 @@ window.__ModuleLoader__.load({
516
569
  ? h(
517
570
  "div",
518
571
  { 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
- )),
572
+ h(
573
+ "div",
574
+ { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
575
+ h(
576
+ "button",
577
+ { style: S.btn, onClick: () => setShowLog((v) => !v) },
578
+ showLog ? "收起更新日志" : "更新日志",
579
+ ),
580
+ h(
581
+ "button",
582
+ { style: S.btn, onClick: () => void onRunLog(), disabled: busy !== "" },
583
+ busy === "logs" ? "读取中…" : showRunLog ? "收起运行日志" : "运行日志",
584
+ ),
585
+ ),
524
586
  showLog ? h("div", { style: S.log }, about.changelog) : null,
587
+ showRunLog && runLog
588
+ ? h(
589
+ "div",
590
+ null,
591
+ h(
592
+ "div",
593
+ { style: { fontSize: 11, opacity: 0.7, marginBottom: 4 } },
594
+ `${runLog.file || "日志"} · 当前 ${sizeText(runLog.bytes)} · 归档 ${(runLog.archives || []).length} 份${runLog.fromArchive ? ` · 含归档 ${runLog.fromArchive}` : ""}`,
595
+ ),
596
+ h("div", { style: S.log }, runLogText(runLog) || "(暂无日志)"),
597
+ )
598
+ : null,
525
599
  )
526
600
  : null,
527
601
  ),
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.3'
26
+ export const VERSION = '0.8.5'
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
  }
@@ -117,6 +117,8 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
117
117
  result.archived.skipped += 1
118
118
  continue
119
119
  }
120
+ // 旧版本的状态里没有 archived 字段;这里顺手补上,避免统计口径漂移
121
+ if (item.archived && !prev.archived && state.sessions[item.sessionId]) prev.archived = true
120
122
  if (prev.bytes === item.size) {
121
123
  result.skipped += 1
122
124
  if (item.archived) result.archived.skipped += 1
@@ -172,13 +174,18 @@ export async function backupSessions({ pan, layout, sessionsRoot, tmpDir, stateF
172
174
  break
173
175
  }
174
176
  }
175
- const archivedDone = archivedAll.filter((x) => state.sessions[x.sessionId] && state.sessions[x.sessionId].archived).length
177
+ const backed = (x) => {
178
+ const row = state.sessions[x.sessionId]
179
+ return Boolean(row && Number(row.bytes || 0) > 0)
180
+ }
181
+ const archivedDone = archivedAll.filter(backed).length
182
+ const activeDone = activeAll.filter(backed).length
176
183
  state.lastRun = {
177
184
  at: Date.now(),
178
185
  archivedTotal: archivedAll.length,
179
186
  activeTotal: activeAll.length,
180
187
  archivedDone,
181
- activeDone: Math.max(0, Object.keys(state.sessions).length - archivedDone),
188
+ activeDone,
182
189
  }
183
190
  saveState(stateFile, { ...state, at: Date.now() })
184
191
  return result
@@ -246,7 +253,7 @@ export function backupSummary(stateFile) {
246
253
  active: Number(run.activeTotal || 0),
247
254
  archivedBackedUp,
248
255
  activeBackedUp,
249
- archivedUntouched: sessions.filter((s) => s.archived).length,
256
+ archivedUntouched: Math.max(0, Number(run.archivedTotal || 0) - archivedBackedUp),
250
257
  bytes: sessions.reduce((a, s) => a + Number(s.bytes || 0), 0),
251
258
  attachments: Object.keys(state.attachments).length,
252
259
  attachmentBytes: Object.values(state.attachments).reduce((a, s) => a + Number(s.size || 0), 0),
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) }]
@@ -1396,6 +1399,8 @@ class CrossTerm {
1396
1399
  if (this.started) return
1397
1400
  this.started = true
1398
1401
  await this.bus.ensure()
1402
+ // 负责人变更留痕:MAINTAINERS.md 里的当前负责人一变,就往 logs/ownership.log 记一行
1403
+ await recordOwnership({ dir: this.bus.logsDir, root: ROOT }).catch(() => {})
1399
1404
  await this.loadLinks()
1400
1405
  await this.note(`links loaded: ${this.links.size} 条`)
1401
1406
  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
@@ -11,6 +11,7 @@ import { createPan } from './cloud/pan.js'
11
11
  import { listCloudDevices, localDevice } 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']
@@ -466,6 +467,11 @@ function readChangelog(maxSections = 3, maxChars = 6000) {
466
467
  }
467
468
  }
468
469
 
470
+ /** 从 MAINTAINERS.md 的机器可读注释里取当前/上一任负责人 */
471
+ function readMaintainers() {
472
+ return readMaintainersDoc(ROOT)
473
+ }
474
+
469
475
  function aboutOf() {
470
476
  if (aboutCache && aboutCache.version === VERSION) return aboutCache
471
477
  let meta = {}
@@ -492,6 +498,7 @@ function aboutOf() {
492
498
  shortTitle: meta.shortTitle || meta.title || PKG,
493
499
  description: meta.description || '',
494
500
  components,
501
+ maintainers: readMaintainers(),
495
502
  changelog: readChangelog(),
496
503
  }
497
504
  return aboutCache
@@ -544,6 +551,45 @@ export function registerWeb(ctx, term, log) {
544
551
  },
545
552
  }),
546
553
  )
554
+ disposers.push(
555
+ webCtx.webServer.register({
556
+ kind: 'exact',
557
+ path: '/crossterm/api/logs',
558
+ handler: async (req, res) => {
559
+ if (!checkHttp(req, res, term.cfg)) return
560
+ try {
561
+ const cfg = term.cfg || {}
562
+ const dir = path.join(cfg.busDir || path.join(os.homedir(), '.dsh', 'crossterm'), 'logs')
563
+ const nodeId = String(term.nodeId || 'node')
564
+ const url = new URL(String(req.url || '/'), 'http://127.0.0.1')
565
+ const lines = Number(url.searchParams.get('lines') || 120)
566
+ const info = await listLogs({ dir, nodeId })
567
+ const tail = await tailLogs({ dir, nodeId, lines, withArchive: true })
568
+ const ownership = await listOwnership({ dir, limit: 10 })
569
+ json(res, 200, {
570
+ ok: true,
571
+ nodeId,
572
+ file: tail.file,
573
+ bytes: info.bytes,
574
+ archives: info.archives.slice(0, 10),
575
+ truncated: tail.truncated,
576
+ fromArchive: tail.fromArchive,
577
+ ownership,
578
+ lines: tail.lines.map((raw) => {
579
+ try {
580
+ const row = JSON.parse(raw)
581
+ return { at: row.at || '', line: String(row.line || '') }
582
+ } catch {
583
+ return { at: '', line: String(raw) }
584
+ }
585
+ }),
586
+ })
587
+ } catch (error) {
588
+ json(res, 200, { ok: false, message: String((error && error.message) || error) })
589
+ }
590
+ },
591
+ }),
592
+ )
547
593
  disposers.push(
548
594
  webCtx.webServer.register({
549
595
  kind: 'exact',
@@ -815,4 +861,4 @@ export function registerWeb(ctx, term, log) {
815
861
  }
816
862
 
817
863
  // 只给探针用:把内部小工具导出来,宿主运行时不依赖这些导出
818
- export { decodeOutput, runtimeTools, looksMissingCommand, updateErrorText, aboutOf }
864
+ 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.3",
3
+ "version": "0.8.5",
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",