@yuanchilin/dsh-mailbox 0.0.1 → 0.0.2
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/README.md +21 -4
- package/bin/mailbox.mjs +25 -2
- package/lib/client.js +16 -5
- package/lib/command.js +14 -7
- package/lib/core.js +251 -21
- package/lib/index.js +61 -6
- package/lib/types/index.d.ts +13 -1
- package/lib/watcher.js +87 -28
- package/package.json +1 -1
- package/skill/README.md +16 -0
- package/skill/SKILL.md +16 -0
- package/skill/mailbox.mjs +106 -14
- package/skill/mailbox.psm1 +93 -14
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ DeepSeek Harness 跨会话文件信箱插件:让多个 DSH 会话 / agent 通
|
|
|
6
6
|
|
|
7
7
|
| 面 | 内容 |
|
|
8
8
|
|---|---|
|
|
9
|
-
| **工具** | `mailbox_send` / `mailbox_recv` / `mailbox_status` / `mailbox_sessions` / `mailbox_alias` / `mailbox_clean` |
|
|
9
|
+
| **工具** | `mailbox_send` / `mailbox_recv` / `mailbox_status` / `mailbox_sessions` / `mailbox_alias` / `mailbox_clean` / `mailbox_reset`(⚠️ 清空整个信箱: 消息+seen+注册表, 需 `confirm=true`) |
|
|
10
10
|
| **命令** | `/mailbox <目标|别名|all> <消息>`(输入框直发);`/mailbox` 空回车列会话目录;`/mailbox recv` 收信;输入 `/mailbox` 弹出**补全选择器** |
|
|
11
11
|
| **内建 watcher** | 新消息 → 为目标会话 agent 创建完成 job → DSH 自动唤醒;随插件启动,**重启自动复活**,无需挂 CLI job |
|
|
12
12
|
| **CLI** | 零依赖 node CLI(`bin/mailbox.mjs`)+ pwsh 双实现(`skill/mailbox.ps1/.psm1`),同协议互通 |
|
|
@@ -19,6 +19,22 @@ DeepSeek Harness 跨会话文件信箱插件:让多个 DSH 会话 / agent 通
|
|
|
19
19
|
- **发现**:`mailbox_sessions` 会话目录(在线/别名/工作区/标题);发送目标未登记时**错误即地址簿**(附完整目录);`to` 支持 identity / 别名 / 完整 sessionId / 工作区路径 / `all`。
|
|
20
20
|
- **默认信箱位置**:`$DSH_HOME/mailbox`(通常 `~/.dsh/mailbox`),无 DSH_HOME 回退 `~/.dsh/mailbox`——可移植,不写死盘符。
|
|
21
21
|
|
|
22
|
+
### 回执 / 确认协议(request → delivered → done|error)
|
|
23
|
+
|
|
24
|
+
`request` 型消息的接收方在收取时**自动回执 `delivered`**(收到确认,幂等防重),处理完再回 `done` / `error`——让"确认"成为协议一等公民,发送方不再是"发了就以为对方办了":
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
发送方 A 信箱文件 接收方 B
|
|
28
|
+
request ───────────────► 收到 ──► 自动回执 delivered ──► A 读到"已收到"
|
|
29
|
+
B 干活/确认方案
|
|
30
|
+
B 处理完回 done/error ◄─── reply (reply_to=原id) ◄───┘ A 读到"已完成/出错"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- 自动回执:`mailbox_recv` 工具、`/mailbox recv` 命令、CLI `recv/wait`、skill 双实现,收取 request 时自动回 `delivered`(CLI/pwsh 可 `--no-ack` / `-NoAck` 关闭)。
|
|
34
|
+
- 完成回执:处理完用 `mailbox_send to=<发送方> type=reply replyTo=<消息id> payload={status:"done"|"error", detail?}`。
|
|
35
|
+
- 查询:`latestReplyStatus(cfg, requestId)` 返回最近回执(含 error 的 detail),发送方据此判断生命周期。
|
|
36
|
+
- 行为策略:涉及执行/计划类任务时,接收 agent 应先向发送方回复确认方案、**等待确认后再动手**(唤醒通知与 recv 输出均带有此提示)。
|
|
37
|
+
|
|
22
38
|
## 安装(DSH 插件)
|
|
23
39
|
|
|
24
40
|
```sh
|
|
@@ -65,7 +81,7 @@ node packages/mailbox/bin/mailbox.mjs sessions --identity me
|
|
|
65
81
|
|
|
66
82
|
```sh
|
|
67
83
|
npm install # 仓库根 (npm workspace)
|
|
68
|
-
npm test # node:test,
|
|
84
|
+
npm test # node:test, 43 用例
|
|
69
85
|
npm run build # 重建浏览器 bundle lib/client.js (提交入库)
|
|
70
86
|
npm run watch:client # bundle 监视重建
|
|
71
87
|
```
|
|
@@ -80,9 +96,10 @@ skill/ SKILL.md + pwsh 双实现 + 示例
|
|
|
80
96
|
test/ node:test 用例
|
|
81
97
|
```
|
|
82
98
|
|
|
83
|
-
## CI
|
|
99
|
+
## CI/CD
|
|
84
100
|
|
|
85
|
-
`.github/workflows/ci.yml`:Node
|
|
101
|
+
- `.github/workflows/ci.yml`:push main/master / PR 触发(可手动),Node 24 双平台(ubuntu + windows):`npm ci` → 测试 → 构建 → 校验 `lib/client.js` 与源码同步 + `npm pack` 发布内容完整性检查。
|
|
102
|
+
- `.github/workflows/publish.yml`:打 `v*` tag(或 `workflow_dispatch` 手动触发)→ 测试/构建/版本一致性校验 → `npm publish --provenance --access public` → 自动创建 GitHub Release。
|
|
86
103
|
|
|
87
104
|
## License
|
|
88
105
|
|
package/bin/mailbox.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// node bin/mailbox.mjs clean --ttl-hours 24 --dry-run
|
|
10
10
|
// node bin/mailbox.mjs status
|
|
11
11
|
// node bin/mailbox.mjs sessions # 会话目录 (注册表: 在线/别名/工作区)
|
|
12
|
-
// node bin/mailbox.mjs init --
|
|
12
|
+
// node bin/mailbox.mjs init --identity agent-a --root D:/Downloads/Agent/.mailbox
|
|
13
13
|
//
|
|
14
14
|
// 配置优先级: 参数 > 环境变量 (MAILBOX_CONFIG/ID/ROOT/INTERVAL/TIMEOUT) > 配置文件 > 默认
|
|
15
15
|
// ============================================================================
|
|
@@ -50,6 +50,7 @@ function getConfig(args) {
|
|
|
50
50
|
}
|
|
51
51
|
const cfg = core.resolveConfig({ ...fileCfg }, process.env);
|
|
52
52
|
if (args.identity) cfg.identity = args.identity;
|
|
53
|
+
if (args.id && !cfg.identity) cfg.identity = args.id; // --id 兼容别名 (注释与旧脚本常用)
|
|
53
54
|
if (args.root) { cfg.root = args.root; cfg.layout = "root"; }
|
|
54
55
|
if (args.interval !== undefined) cfg.intervalSec = Number(args.interval);
|
|
55
56
|
if (args.timeout !== undefined) cfg.timeoutSec = Number(args.timeout);
|
|
@@ -61,7 +62,7 @@ function getConfig(args) {
|
|
|
61
62
|
/** 心跳是 best-effort: 共享根不可写(沙箱)时静默跳过, 不影响主流程 (尤其 wait 监听循环)。 */
|
|
62
63
|
function safeTouchCli(cfg) {
|
|
63
64
|
try {
|
|
64
|
-
|
|
65
|
+
core.touchRegistryCli(cfg);
|
|
65
66
|
} catch {
|
|
66
67
|
// 忽略: 心跳失败不应让 send/recv/wait/poll/status 崩溃
|
|
67
68
|
}
|
|
@@ -111,6 +112,10 @@ const commands = {
|
|
|
111
112
|
safeTouchCli(cfg);
|
|
112
113
|
const msgs = core.recvNew(cfg, true);
|
|
113
114
|
if (msgs.length === 0) { console.log("(无新消息)"); return; }
|
|
115
|
+
let acked = 0;
|
|
116
|
+
if (!args["no-ack"]) {
|
|
117
|
+
for (const m of msgs) if (m.type === "request" && core.sendAck(cfg, m, "delivered")) acked++;
|
|
118
|
+
}
|
|
114
119
|
if (args.format === "json") {
|
|
115
120
|
for (const m of msgs) console.log(JSON.stringify(m));
|
|
116
121
|
} else {
|
|
@@ -119,6 +124,7 @@ const commands = {
|
|
|
119
124
|
console.log(`[${m.from} -> ${m.to}] ${m.type} topic=${m.topic} id=${m.id}${m.reply_to ? ` reply_to=${m.reply_to}` : ""}${p}`);
|
|
120
125
|
}
|
|
121
126
|
}
|
|
127
|
+
if (acked > 0) console.log(`[mailbox] 已自动回执 delivered 给 ${acked} 条 request ✓ (--no-ack 可关闭)`);
|
|
122
128
|
},
|
|
123
129
|
|
|
124
130
|
async wait(args, cfg) {
|
|
@@ -128,8 +134,13 @@ const commands = {
|
|
|
128
134
|
for (;;) {
|
|
129
135
|
const msgs = core.recvNew(cfg, true);
|
|
130
136
|
if (msgs.length > 0) {
|
|
137
|
+
let acked = 0;
|
|
138
|
+
if (!args["no-ack"]) {
|
|
139
|
+
for (const m of msgs) if (m.type === "request" && core.sendAck(cfg, m, "delivered")) acked++;
|
|
140
|
+
}
|
|
131
141
|
console.log(`=== NEW MESSAGES: ${msgs.length} ===`);
|
|
132
142
|
for (const m of msgs) console.log(JSON.stringify(m));
|
|
143
|
+
if (acked > 0) console.log(`[mailbox] 已自动回执 delivered 给 ${acked} 条 request ✓ (--no-ack 可关闭)`);
|
|
133
144
|
console.log("=== WAKE-UP (exit 0) ===");
|
|
134
145
|
process.exit(0);
|
|
135
146
|
}
|
|
@@ -179,6 +190,18 @@ const commands = {
|
|
|
179
190
|
console.log(`clean: ${args.dryRun ? "dry-run" : "已删除"} ${removed} 条过期消息`);
|
|
180
191
|
},
|
|
181
192
|
|
|
193
|
+
reset(args, cfg) {
|
|
194
|
+
core.assertUsable(cfg);
|
|
195
|
+
// parseArgs 不拆 --k=v, --confirm=true 会落到键 "confirm=true", 兼容两种写法
|
|
196
|
+
if (args["confirm=true"] === true) args.confirm = true;
|
|
197
|
+
if (args.confirm !== true && String(args.confirm) !== "true") {
|
|
198
|
+
throw new Error("reset 需要 --confirm (或 --confirm=true) (清空整个信箱: 消息+seen+注册表, 不可恢复)");
|
|
199
|
+
}
|
|
200
|
+
const r = core.resetMailbox(cfg);
|
|
201
|
+
console.log(`reset 完成: 删除消息 ${r.messages} 条, seen ${r.seen} 条, 注册表 ${r.registry} 条`);
|
|
202
|
+
console.log("(各会话下次调用 mailbox 工具会自动重新登记身份/别名)");
|
|
203
|
+
},
|
|
204
|
+
|
|
182
205
|
status(args, cfg) {
|
|
183
206
|
core.assertUsable(cfg);
|
|
184
207
|
safeTouchCli(cfg);
|
package/lib/client.js
CHANGED
|
@@ -69,19 +69,30 @@ window.__ModuleLoader__.load({
|
|
|
69
69
|
ui: {
|
|
70
70
|
kind: "popupSelect",
|
|
71
71
|
options: async (session) => {
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
const commandActions = [{
|
|
73
|
+
id: "recv",
|
|
74
|
+
label: "📥 recv — 收取发给我的新消息",
|
|
75
|
+
detail: "读取当前会话收件箱(自动去重)",
|
|
76
|
+
isCommand: true
|
|
77
|
+
}, {
|
|
78
|
+
id: "list",
|
|
79
|
+
label: "📇 list — 列出会话目录",
|
|
80
|
+
detail: "查看所有会话身份/别名/在线状态",
|
|
81
|
+
isCommand: true
|
|
82
|
+
}];
|
|
83
|
+
if (remote.commands === void 0) return commandActions;
|
|
84
|
+
const result = await remote.commands.execute(session.sessionId, "/mailbox", []);
|
|
74
85
|
if (!result.ok || result.value === void 0) throw new Error(`mailbox 目录获取失败: ${result.error?.message ?? result.error?.code ?? "unknown"}`);
|
|
75
86
|
const rows = parseDirectory(result.value.result?.text ?? "");
|
|
76
|
-
|
|
77
|
-
return rows;
|
|
87
|
+
return [...commandActions, ...rows];
|
|
78
88
|
},
|
|
79
89
|
onSelect: async (option, session) => {
|
|
80
90
|
const actx = sessions.scope(session.sessionId);
|
|
81
91
|
if (actx === void 0) return;
|
|
82
92
|
const input = scope.get("conversation")?.input?.for(actx);
|
|
83
93
|
if (input === void 0) return;
|
|
84
|
-
|
|
94
|
+
const draft = option.isCommand ? `/mailbox ${option.id}` : `/mailbox ${option.id} `;
|
|
95
|
+
input.setDraft(draft);
|
|
85
96
|
setTimeout(() => {
|
|
86
97
|
const list = document.querySelectorAll("textarea[data-phase]");
|
|
87
98
|
let target = null;
|
package/lib/command.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @yuanchilin/dsh-mailbox — 宿主 `/mailbox` 命令
|
|
3
3
|
//
|
|
4
4
|
// 在聊天输入框直接使用 (服务端命令注册表, 每个会话独立执行):
|
|
5
|
-
// /mailbox →
|
|
5
|
+
// /mailbox → 会话目录 (发现对方)
|
|
6
6
|
// /mailbox <target|alias|all> <message> → 定向/别名/广播发送
|
|
7
7
|
// /mailbox recv → 收取发给我的新消息
|
|
8
8
|
// /mailbox list → 会话目录
|
|
@@ -13,14 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import * as core from "./core.js";
|
|
15
15
|
|
|
16
|
-
export const MAILBOX_USAGE = "Usage: /mailbox <target|alias|all> <message> | /mailbox [list|recv]";
|
|
17
|
-
|
|
18
16
|
/** 解析命令输入 (首 token 为目标, 其余为消息)。 */
|
|
19
17
|
export function parseMailboxCommand(rawInput) {
|
|
20
18
|
const input = String(rawInput ?? "").trim();
|
|
21
19
|
if (input.length === 0) return { kind: "usage" };
|
|
22
20
|
const lower = input.toLowerCase();
|
|
23
|
-
if (lower === "list" || lower === "
|
|
21
|
+
if (lower === "list" || lower === "help" || lower === "?") return { kind: "list" };
|
|
24
22
|
if (lower === "recv") return { kind: "recv" };
|
|
25
23
|
const sp = input.search(/\s/);
|
|
26
24
|
if (sp === -1) return { kind: "no-message", to: input };
|
|
@@ -53,20 +51,29 @@ export function executeMailboxCommand(ctx, cfg, invocation) {
|
|
|
53
51
|
const parsed = parseMailboxCommand(invocation.rawInput);
|
|
54
52
|
switch (parsed.kind) {
|
|
55
53
|
case "usage":
|
|
56
|
-
|
|
54
|
+
// 不再打印 "Usage: ..." 帮助行:用法提示已在弹窗补全与命令 hint 中提供,
|
|
55
|
+
// 裸 /mailbox 只承担"发现会话目录"职责,避免每次都先冒出一条 Usage 噪音。
|
|
56
|
+
return { kind: "success", text: `会话目录:\n${renderDirectory(eff)}` };
|
|
57
57
|
case "list":
|
|
58
58
|
return { kind: "success", text: `会话目录:\n${renderDirectory(eff)}` };
|
|
59
59
|
case "recv": {
|
|
60
60
|
const msgs = core.recvNew(eff, true);
|
|
61
61
|
if (msgs.length === 0) return { kind: "success", text: "(无新消息)" };
|
|
62
|
+
let acked = 0;
|
|
63
|
+
for (const m of msgs) {
|
|
64
|
+
if (m.type === "request" && core.sendAck(eff, m, "delivered")) acked++;
|
|
65
|
+
}
|
|
62
66
|
const lines = msgs.map((m) => {
|
|
63
67
|
const p = m.payload && Object.keys(m.payload).length ? ` payload=${JSON.stringify(m.payload)}` : "";
|
|
64
68
|
return `[${m.from} -> ${m.to}] ${m.type} topic=${m.topic} id=${m.id}${m.reply_to ? ` reply_to=${m.reply_to}` : ""}${p}`;
|
|
65
69
|
});
|
|
66
|
-
|
|
70
|
+
const hint = acked > 0
|
|
71
|
+
? `\n已自动回执 delivered 给 ${acked} 条 request ✓ (处理完请用 mailbox_send type=reply replyTo=<消息id> 回 done/error)`
|
|
72
|
+
: "";
|
|
73
|
+
return { kind: "success", text: `新消息 ${msgs.length} 条:\n${lines.join("\n")}${hint}` };
|
|
67
74
|
}
|
|
68
75
|
case "no-message":
|
|
69
|
-
return { kind: "error", text: `/mailbox ${parsed.to}:
|
|
76
|
+
return { kind: "error", text: `/mailbox ${parsed.to}: 缺少消息内容(用法:/mailbox <目标|别名|all> <消息>)` };
|
|
70
77
|
case "send": {
|
|
71
78
|
const target = core.resolveTarget(eff, parsed.to);
|
|
72
79
|
const id = core.sendMessage(eff, {
|
package/lib/core.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
// - layout=dirs: dirs: { "<id>": "<目录>" } 显式映射 (旧双目录兼容)
|
|
8
8
|
// - 消息: { id, from, to, type, topic, payload, ts, reply_to }, 文件 msg_<id>.json
|
|
9
9
|
// - 路由: to=<id> 定向 / to=all 广播 (写一份, 各人自取)
|
|
10
|
-
// - seen 去重: 每参与者独立 seen
|
|
10
|
+
// - seen 去重: 每参与者独立 seen 目录 (默认 <outDir>/.seen/, 每消息一个 .seen 标记, 原子写)
|
|
11
11
|
// ============================================================================
|
|
12
12
|
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, renameSync } from "node:fs";
|
|
14
14
|
import { join, dirname, basename } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
16
|
|
|
@@ -32,11 +32,17 @@ export const DEFAULTS = {
|
|
|
32
32
|
patchRoot: "",
|
|
33
33
|
presenceWindowSec: 300, // 在线判定窗口 (秒): lastSeen 在该窗口内视为在线
|
|
34
34
|
watcher: true, // 插件内建监听 (新消息 → 唤醒会话 agent; 重启自动复活)
|
|
35
|
+
wakeRetrySec: 300, // watcher 已触发唤醒后的持久化抑制窗口 (秒)
|
|
35
36
|
};
|
|
36
37
|
|
|
37
38
|
/** 合并默认值(可选: 环境变量 > 显式覆盖)。插件侧直接传 Config 对象, CLI 侧先加载配置文件。 */
|
|
38
39
|
export function resolveConfig(partial = {}, env = {}) {
|
|
39
40
|
const cfg = { ...DEFAULTS, ...partial };
|
|
41
|
+
// schema 默认 "" 会覆盖 DEFAULTS 的回退值 (如 root=DSH_HOME/mailbox),
|
|
42
|
+
// 导致零配置下 root 为空 → 注册表无法写入 → 会话目录永远为空。
|
|
43
|
+
// 空 root / patchRoot 视为"未配置", 回退到 DEFAULTS。
|
|
44
|
+
if (!cfg.root) cfg.root = DEFAULTS.root;
|
|
45
|
+
if (!cfg.patchRoot) cfg.patchRoot = DEFAULTS.patchRoot;
|
|
40
46
|
if (env.MAILBOX_ID) cfg.identity = env.MAILBOX_ID;
|
|
41
47
|
if (env.MAILBOX_ROOT) { cfg.root = env.MAILBOX_ROOT; cfg.layout = "root"; }
|
|
42
48
|
if (env.MAILBOX_INTERVAL) cfg.intervalSec = Number(env.MAILBOX_INTERVAL);
|
|
@@ -67,27 +73,110 @@ export function resolveDirs(cfg) {
|
|
|
67
73
|
return { out, in: inDirs };
|
|
68
74
|
}
|
|
69
75
|
|
|
76
|
+
const SEEN_EXT = ".seen";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* seen 旧默认文件路径 (仅用于迁移检测; 显式 cfg.seenFile 时作为单文件存储路径)。
|
|
80
|
+
*/
|
|
70
81
|
export function seenFileOf(cfg) {
|
|
71
82
|
if (cfg.seenFile) return cfg.seenFile;
|
|
72
83
|
return join(resolveDirs(cfg).out, ".seen.json");
|
|
73
84
|
}
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
/**
|
|
87
|
+
* 目录化 seen 的存储目录。显式配置 cfg.seenFile 时走旧"单 JSON 数组文件"
|
|
88
|
+
* 兼容路径, 返回 "" (此时用 seenFileOf)。
|
|
89
|
+
*
|
|
90
|
+
* 默认目录化: 每条已读消息一个 `.seen` 标记文件, 通过临时文件 + 原子 rename
|
|
91
|
+
* 写入 —— 多进程/多主机并发 recv 互不覆盖, 根治单文件"读改写"丢 seen 记录。
|
|
92
|
+
*/
|
|
93
|
+
function seenDirOf(cfg) {
|
|
94
|
+
if (cfg.seenFile) return "";
|
|
95
|
+
return join(resolveDirs(cfg).out, ".seen");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function seenMarkName(id) { return `${id}${SEEN_EXT}`; }
|
|
99
|
+
|
|
100
|
+
/** 目录模式下, 把旧的单文件 .seen.json 迁移到 .seen/ 目录 (幂等; 显式 seenFile 时跳过)。 */
|
|
101
|
+
function migrateSeen(cfg, dir) {
|
|
102
|
+
const legacy = seenFileOf(cfg);
|
|
103
|
+
if (cfg.seenFile || !existsSync(legacy) || existsSync(dir)) return;
|
|
78
104
|
try {
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
105
|
+
const v = JSON.parse(readFileSync(legacy, "utf-8"));
|
|
106
|
+
const arr = Array.isArray(v) ? v : [v];
|
|
107
|
+
mkdirSync(dir, { recursive: true });
|
|
108
|
+
for (const id of arr) {
|
|
109
|
+
if (id) writeFileSync(join(dir, seenMarkName(id)), "", "utf-8");
|
|
110
|
+
}
|
|
111
|
+
rmSync(legacy, { force: true });
|
|
82
112
|
} catch {
|
|
83
|
-
|
|
113
|
+
// 迁移失败则保留旧文件; 目录不在时单文件 loadSeen/markSeen 仍兼容
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function loadSeen(cfg) {
|
|
118
|
+
const dir = seenDirOf(cfg);
|
|
119
|
+
if (!dir) { // 单文件模式 (显式 seenFile)
|
|
120
|
+
const f = seenFileOf(cfg);
|
|
121
|
+
if (!existsSync(f)) return [];
|
|
122
|
+
try {
|
|
123
|
+
// pwsh 旧版本可能把单元素 seen 写成裸字符串 "id", 归一化为数组
|
|
124
|
+
const v = JSON.parse(readFileSync(f, "utf-8"));
|
|
125
|
+
return Array.isArray(v) ? v : [v];
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
84
129
|
}
|
|
130
|
+
migrateSeen(cfg, dir);
|
|
131
|
+
if (!existsSync(dir)) return [];
|
|
132
|
+
return readdirSync(dir)
|
|
133
|
+
.filter((x) => x.endsWith(SEEN_EXT))
|
|
134
|
+
.map((x) => x.slice(0, -SEEN_EXT.length));
|
|
85
135
|
}
|
|
86
136
|
|
|
137
|
+
/** 批量补写 (迁移/兼容调用; 目录模式逐条原子标记)。 */
|
|
87
138
|
export function saveSeen(cfg, seen) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
139
|
+
const dir = seenDirOf(cfg);
|
|
140
|
+
if (!dir) { // 单文件模式
|
|
141
|
+
const f = seenFileOf(cfg);
|
|
142
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
143
|
+
writeFileSync(f, JSON.stringify([...new Set(seen)]));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
for (const id of new Set(seen)) {
|
|
148
|
+
if (id) markSeen(cfg, id);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 标记单条已读: 目录模式临时文件 + rename 原子写入, 并发安全; 单文件模式读改写尽力兼容。 */
|
|
153
|
+
export function markSeen(cfg, id) {
|
|
154
|
+
if (!id) return;
|
|
155
|
+
const dir = seenDirOf(cfg);
|
|
156
|
+
if (!dir) { // 单文件: 读改写 (显式配置, 为一次并发安全做尽力而为)
|
|
157
|
+
const cur = loadSeen(cfg);
|
|
158
|
+
if (!cur.includes(id)) saveSeen(cfg, [...cur, id]);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
migrateSeen(cfg, dir);
|
|
162
|
+
mkdirSync(dir, { recursive: true });
|
|
163
|
+
const mark = join(dir, seenMarkName(id));
|
|
164
|
+
if (existsSync(mark)) return;
|
|
165
|
+
const tmp = join(dir, `.tmp-${id}`);
|
|
166
|
+
writeFileSync(tmp, "", "utf-8");
|
|
167
|
+
renameSync(tmp, mark); // 原子 rename: 目录模式下并发写互不覆盖
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function clearWake(cfg, id) {
|
|
171
|
+
if (!id) return;
|
|
172
|
+
rmSync(join(resolveDirs(cfg).out, ".wake", `${encodeURIComponent(id)}.json`), { force: true });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 单条已读判定: 目录模式 O(1) 存在性检查 (供 watcher/unread 高效判断)。 */
|
|
176
|
+
export function isSeen(cfg, id) {
|
|
177
|
+
const dir = seenDirOf(cfg);
|
|
178
|
+
if (!dir) return loadSeen(cfg).includes(id);
|
|
179
|
+
return existsSync(join(dir, seenMarkName(id)));
|
|
91
180
|
}
|
|
92
181
|
|
|
93
182
|
function newId() {
|
|
@@ -106,12 +195,15 @@ export function sendMessage(cfg, { to, type = "notify", topic = "", payload = {}
|
|
|
106
195
|
mkdirSync(dirs.out, { recursive: true });
|
|
107
196
|
const id = newId();
|
|
108
197
|
const msg = { id, from: cfg.identity, to: target, type, topic, payload, ts: Date.now(), reply_to: replyTo };
|
|
109
|
-
|
|
198
|
+
const finalPath = join(dirs.out, `msg_${id}.json`);
|
|
199
|
+
const tempPath = join(dirs.out, `.tmp-msg-${id}`);
|
|
200
|
+
writeFileSync(tempPath, JSON.stringify(msg) + "\n", "utf-8");
|
|
201
|
+
renameSync(tempPath, finalPath);
|
|
110
202
|
return id;
|
|
111
203
|
}
|
|
112
204
|
|
|
113
|
-
/** 接收: 扫描所有对方目录, 取 to=自己 或 to=all 且未 seen 的消息。
|
|
114
|
-
export function recvNew(cfg,
|
|
205
|
+
/** 接收: 扫描所有对方目录, 取 to=自己 或 to=all 且未 seen 的消息。mark 默认 true。 */
|
|
206
|
+
export function recvNew(cfg, mark = true) {
|
|
115
207
|
const seen = loadSeen(cfg);
|
|
116
208
|
const dirs = resolveDirs(cfg);
|
|
117
209
|
const fresh = [];
|
|
@@ -122,17 +214,93 @@ export function recvNew(cfg, markSeen = true) {
|
|
|
122
214
|
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
123
215
|
if ((m.to === cfg.identity || m.to === "all") && !seen.includes(m.id)) {
|
|
124
216
|
fresh.push(m);
|
|
125
|
-
if (
|
|
217
|
+
if (mark) {
|
|
218
|
+
markSeen(cfg, m.id);
|
|
219
|
+
clearWake(cfg, m.id);
|
|
220
|
+
}
|
|
126
221
|
}
|
|
127
222
|
} catch {
|
|
128
223
|
// 跳过损坏消息
|
|
129
224
|
}
|
|
130
225
|
}
|
|
131
226
|
}
|
|
132
|
-
if (markSeen) saveSeen(cfg, seen);
|
|
133
227
|
return fresh;
|
|
134
228
|
}
|
|
135
229
|
|
|
230
|
+
// ---- 回执协议 (ack): 让"确认"成为协议一等公民 ----
|
|
231
|
+
//
|
|
232
|
+
// 生命周期: request 被消费时接收方自动回 delivered → 处理完回 done/error。
|
|
233
|
+
// 回执即普通 reply 消息 (topic="status", payload.status, reply_to=原消息id),
|
|
234
|
+
// 写接收方自己的目录, 发送方扫描对方目录即可读到 —— 不需要新通道。
|
|
235
|
+
// 幂等: 同一 (requestId, status) 只回一次, 防重启/重复消费导致重复回执。
|
|
236
|
+
|
|
237
|
+
export const ACK_TOPIC = "status";
|
|
238
|
+
const ACK_STATUSES = ["delivered", "processing", "done", "error"];
|
|
239
|
+
|
|
240
|
+
/** 本会话是否已对 requestId 发过指定 status 的回执 (扫自己发件目录, 幂等防重)。 */
|
|
241
|
+
export function hasAck(cfg, requestId, status) {
|
|
242
|
+
if (!requestId || !status) return false;
|
|
243
|
+
const dirs = resolveDirs(cfg);
|
|
244
|
+
if (!existsSync(dirs.out)) return false;
|
|
245
|
+
for (const f of readdirSync(dirs.out)) {
|
|
246
|
+
if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
|
|
247
|
+
try {
|
|
248
|
+
const m = JSON.parse(readFileSync(join(dirs.out, f), "utf-8"));
|
|
249
|
+
if (m.reply_to === requestId && m.payload?.status === status) return true;
|
|
250
|
+
} catch {
|
|
251
|
+
// 跳过损坏消息
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 回执: 向 request 消息的发送者回一条 reply (status: delivered|processing|done|error)。
|
|
259
|
+
* 返回 true=已写入回执, false=跳过 (参数缺失 / 发给自己 / 幂等已存在)。
|
|
260
|
+
*/
|
|
261
|
+
export function sendAck(cfg, request, status, extra = {}) {
|
|
262
|
+
if (!request || !request.id || !request.from || !ACK_STATUSES.includes(status)) return false;
|
|
263
|
+
if (request.from === cfg.identity) return false;
|
|
264
|
+
if (hasAck(cfg, request.id, status)) return false;
|
|
265
|
+
sendMessage(cfg, {
|
|
266
|
+
to: request.from,
|
|
267
|
+
type: "reply",
|
|
268
|
+
topic: ACK_TOPIC,
|
|
269
|
+
payload: { status, requestId: request.id, ...extra },
|
|
270
|
+
replyTo: request.id,
|
|
271
|
+
});
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 查询 requestId 的最近回执: 扫描各对方目录中 reply_to===requestId 的消息。 */
|
|
276
|
+
// 决胜规则 (确定性, 不依赖 readdir 顺序/平台): 先比 ts, 同 ms 再比状态语义
|
|
277
|
+
// 优先级 error > done > processing > delivered —— 终态永远压过中间态。
|
|
278
|
+
const ACK_WEIGHT = { error: 3, done: 2, processing: 1, delivered: 0 };
|
|
279
|
+
export function latestReplyStatus(cfg, requestId, expectFrom = "") {
|
|
280
|
+
let best = null; // { ts, status, id, from, payload }
|
|
281
|
+
for (const dir of resolveDirs(cfg).in) {
|
|
282
|
+
if (!existsSync(dir)) continue;
|
|
283
|
+
for (const f of readdirSync(dir).filter((x) => x.startsWith("msg_") && x.endsWith(".json")).sort()) {
|
|
284
|
+
try {
|
|
285
|
+
const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
|
|
286
|
+
if (m.reply_to !== requestId) continue;
|
|
287
|
+
if (expectFrom && m.from !== expectFrom) continue;
|
|
288
|
+
if (m.payload?.status) {
|
|
289
|
+
const ts = m.ts ?? 0;
|
|
290
|
+
const w = ACK_WEIGHT[m.payload.status] ?? 0;
|
|
291
|
+
const bw = best ? (ACK_WEIGHT[best.status] ?? 0) : -1;
|
|
292
|
+
if (!best || ts > best.ts || (ts === best.ts && w > bw)) {
|
|
293
|
+
best = { ts, status: m.payload.status, id: m.id, from: m.from, payload: m.payload };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
// 跳过损坏消息
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return best;
|
|
302
|
+
}
|
|
303
|
+
|
|
136
304
|
/** 按 id 删除消息: inbox=true 删对方目录(已处理), 否则删自己的目录(已发送)。 */
|
|
137
305
|
export function removeMessage(cfg, id, inbox = false) {
|
|
138
306
|
const dirs = resolveDirs(cfg);
|
|
@@ -155,6 +323,54 @@ export function removeMessage(cfg, id, inbox = false) {
|
|
|
155
323
|
return false;
|
|
156
324
|
}
|
|
157
325
|
|
|
326
|
+
/**
|
|
327
|
+
* 重置信箱: 清空所有参与者目录的消息 (msg_*.json)、已读标记 (.seen/ 与旧 .seen.json)、
|
|
328
|
+
* 唤醒标记 (.wake/) 以及会话注册表 (_sessions/, 含别名)。保留根目录本身 (可能是挂载点/配置根)。
|
|
329
|
+
* 返回各类删除计数; 各会话下次调用 mailbox 工具会自动重新登记身份。
|
|
330
|
+
* ⚠️ 破坏性: 全会话共享数据, 不可恢复。调用方负责确认 (工具层要求 confirm=true)。
|
|
331
|
+
*/
|
|
332
|
+
export function resetMailbox(cfg) {
|
|
333
|
+
const dirs = resolveDirs(cfg);
|
|
334
|
+
const targets = new Set([dirs.out, ...dirs.in]);
|
|
335
|
+
const removed = { messages: 0, seen: 0, registry: 0 };
|
|
336
|
+
const reg = registryDir(cfg);
|
|
337
|
+
if (existsSync(reg)) {
|
|
338
|
+
try {
|
|
339
|
+
removed.registry = readdirSync(reg).length;
|
|
340
|
+
rmSync(reg, { recursive: true, force: true });
|
|
341
|
+
} catch {
|
|
342
|
+
removed.registry = 0;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const dir of targets) {
|
|
346
|
+
if (!dir || !existsSync(dir)) continue;
|
|
347
|
+
for (const f of readdirSync(dir)) {
|
|
348
|
+
const p = join(dir, f);
|
|
349
|
+
try {
|
|
350
|
+
const st = statSync(p);
|
|
351
|
+
if (st.isDirectory()) {
|
|
352
|
+
if (f === ".seen" || f === ".wake") {
|
|
353
|
+
// .wake 唤醒标记与 .seen 同属点目录状态文件, 一并计入 seen 计数
|
|
354
|
+
removed.seen += readdirSync(p).length;
|
|
355
|
+
rmSync(p, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (f.startsWith("msg_") && f.endsWith(".json")) {
|
|
360
|
+
removed.messages++;
|
|
361
|
+
rmSync(p, { force: true });
|
|
362
|
+
} else if (f === ".seen.json") {
|
|
363
|
+
removed.seen++;
|
|
364
|
+
rmSync(p, { force: true });
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// 单个文件处理失败跳过
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return removed;
|
|
372
|
+
}
|
|
373
|
+
|
|
158
374
|
/** TTL 清理: 删除自己 OutDir 中超过 ttlHours 的已发送消息 (收方应已读过)。返回删除数。 */
|
|
159
375
|
export function cleanTTL(cfg, { ttlHours = 24, dryRun = false } = {}) {
|
|
160
376
|
const dirs = resolveDirs(cfg);
|
|
@@ -166,6 +382,8 @@ export function cleanTTL(cfg, { ttlHours = 24, dryRun = false } = {}) {
|
|
|
166
382
|
const p = join(dirs.out, f);
|
|
167
383
|
try {
|
|
168
384
|
if (statSync(p).mtimeMs < cutoff) {
|
|
385
|
+
const message = JSON.parse(readFileSync(p, "utf-8"));
|
|
386
|
+
if (message.type === "request" && !latestReplyStatus(cfg, message.id)) continue;
|
|
169
387
|
if (!dryRun) rmSync(p, { force: true });
|
|
170
388
|
removed++;
|
|
171
389
|
}
|
|
@@ -274,6 +492,14 @@ function registryFileOf(cfg, key) {
|
|
|
274
492
|
return dir ? join(dir, `${key}.json`) : "";
|
|
275
493
|
}
|
|
276
494
|
|
|
495
|
+
function writeRegistry(cfg, key, value) {
|
|
496
|
+
const file = registryFileOf(cfg, key);
|
|
497
|
+
const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
498
|
+
writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", "utf-8");
|
|
499
|
+
rmSync(file, { force: true });
|
|
500
|
+
renameSync(temp, file);
|
|
501
|
+
}
|
|
502
|
+
|
|
277
503
|
function readRegistry(cfg, key) {
|
|
278
504
|
const f = registryFileOf(cfg, key);
|
|
279
505
|
if (!f || !existsSync(f)) return undefined;
|
|
@@ -302,7 +528,7 @@ export function touchRegistry(cfg, session, { alias } = {}) {
|
|
|
302
528
|
lastSeen: now,
|
|
303
529
|
};
|
|
304
530
|
mkdirSync(dir, { recursive: true });
|
|
305
|
-
|
|
531
|
+
writeRegistry(cfg, key, rec);
|
|
306
532
|
return rec;
|
|
307
533
|
}
|
|
308
534
|
|
|
@@ -323,7 +549,7 @@ export function touchRegistryCli(cfg, { workspace = "" } = {}) {
|
|
|
323
549
|
lastSeen: now,
|
|
324
550
|
};
|
|
325
551
|
mkdirSync(dir, { recursive: true });
|
|
326
|
-
|
|
552
|
+
writeRegistry(cfg, key, rec);
|
|
327
553
|
return rec;
|
|
328
554
|
}
|
|
329
555
|
|
|
@@ -384,11 +610,15 @@ export function resolveTarget(cfg, to) {
|
|
|
384
610
|
// 按工作区匹配: 完整路径 (正反斜杠归一) 或目录名 (如 D:/.../dsh-mailbox 或 dsh-mailbox)
|
|
385
611
|
const norm = (p) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
386
612
|
const targetNorm = norm(to);
|
|
387
|
-
const
|
|
613
|
+
const workspaceMatches = sessions.filter((s) => {
|
|
388
614
|
const ws = norm(s.workspace);
|
|
389
615
|
return ws === targetNorm || ws.split("/").pop() === targetNorm;
|
|
390
616
|
});
|
|
391
|
-
if (
|
|
617
|
+
if (workspaceMatches.length > 1) {
|
|
618
|
+
const identities = workspaceMatches.map((s) => s.identity).filter(Boolean).join(", ");
|
|
619
|
+
throw new Error(`workspace '${to}' 对应多个会话 (${identities}),请使用 identity、alias 或完整 sessionId`);
|
|
620
|
+
}
|
|
621
|
+
if (workspaceMatches.length === 1) return workspaceMatches[0].identity || to;
|
|
392
622
|
return to;
|
|
393
623
|
}
|
|
394
624
|
|