dsh-crossterm 0.8.4 → 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 +18 -0
- package/MAINTAINERS.md +73 -0
- package/README.md +36 -13
- package/client/client.js +79 -5
- package/lib/bus.js +16 -1
- package/lib/index.js +5 -0
- package/lib/logs.js +192 -0
- package/lib/web.js +47 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## 0.8.4 — 2026-10-19
|
|
4
22
|
|
|
5
23
|
### 修备份统计口径:卡片上「已归档 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,18 +28,23 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
|
|
|
28
28
|
> `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
|
|
29
29
|
> 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
|
|
30
30
|
|
|
31
|
-
**本版更新(0.8.
|
|
32
|
-
|
|
33
|
-
-
|
|
34
|
-
|
|
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`。
|
|
35
47
|
- 备份行为本身没变:归档会话仍然只传一次,活跃会话仍按水位增量。
|
|
36
|
-
|
|
37
|
-
**上一版(0.8.3)**
|
|
38
|
-
|
|
39
|
-
- **已归档会话不再反复备份**:备份前先读 DSH 的归档清单(`storages/workspace.json`),
|
|
40
|
-
归档会话第一次传完就标记,之后每轮直接跳过(本机 24 个已归档、9 个活跃中)。
|
|
41
|
-
- **卡片细分**:「会话备份」行改成 `已归档 24/24 · 活跃中 9/9 · 43.1 MB · 上次 …`,
|
|
42
|
-
一眼看出哪类备份完了。
|
|
43
48
|
- **活跃会话照旧增量**:从 `_session.json` 的水位往后传,不会重传整份。
|
|
44
49
|
|
|
45
50
|
**上一版(0.8.2)**
|
|
@@ -492,8 +497,26 @@ dsh/
|
|
|
492
497
|
- 收到消息后**会作为用户消息进入会话**,agent 会据此行动——所以总线目录只应放在你信任的位置;
|
|
493
498
|
共享盘方式下,谁能写这个目录,谁就能往你的会话里塞话。
|
|
494
499
|
- 文件落地目录可被 agent 读写,`inboxDir` 建议指向专门目录而非系统目录。
|
|
495
|
-
-
|
|
496
|
-
|
|
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`(更新过程)。
|
|
497
520
|
|
|
498
521
|
## 九、开发自测
|
|
499
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(
|
|
520
|
-
"
|
|
521
|
-
{ style:
|
|
522
|
-
|
|
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.
|
|
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
|
}
|
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
|
+
"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",
|