@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 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, 30 用例
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 22/24,`npm ci` → 测试 → 构建 → 校验 `lib/client.js` 与源码同步。
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 --id agent-a --root D:/Downloads/Agent/.mailbox
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
- safeTouchCli(cfg);
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
- if (remote.commands === void 0) return [];
73
- const result = await remote.commands.execute(session.sessionId, "/mailbox");
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
- if (rows.length === 0) throw new Error("mailbox 目录为空 (暂无注册会话)");
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
- input.setDraft(`/mailbox ${option.id} `);
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 === "sessions" || lower === "help" || lower === "?") return { kind: "list" };
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
- return { kind: "success", text: `${MAILBOX_USAGE}\n\n会话目录:\n${renderDirectory(eff)}` };
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
- return { kind: "success", text: `新消息 ${msgs.length} 条:\n${lines.join("\n")}` };
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}: 缺少消息内容。${MAILBOX_USAGE}` };
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 文件 (默认 <outDir>/.seen.json)
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
- export function loadSeen(cfg) {
76
- const f = seenFileOf(cfg);
77
- if (!existsSync(f)) return [];
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
- // pwsh 旧版本可能把单元素 seen 写成裸字符串 "id", 归一化为数组
80
- const v = JSON.parse(readFileSync(f, "utf-8"));
81
- return Array.isArray(v) ? v : [v];
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
- return [];
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 f = seenFileOf(cfg);
89
- mkdirSync(dirname(f), { recursive: true });
90
- writeFileSync(f, JSON.stringify([...new Set(seen)]));
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
- writeFileSync(join(dirs.out, `msg_${id}.json`), JSON.stringify(msg) + "\n", "utf-8");
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 的消息。markSeen 默认 true。 */
114
- export function recvNew(cfg, markSeen = true) {
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 (markSeen) seen.push(m.id);
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
- writeFileSync(registryFileOf(cfg, key), JSON.stringify(rec, null, 2) + "\n", "utf-8");
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
- writeFileSync(registryFileOf(cfg, key), JSON.stringify(rec, null, 2) + "\n", "utf-8");
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 byWorkspace = sessions.find((s) => {
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 (byWorkspace) return byWorkspace.identity || to;
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