@yuanchilin/dsh-mailbox 0.0.1

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.
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // mailbox.mjs — 通用跨会话文件信箱 CLI v1 (Node 版, 零依赖)
4
+ //
5
+ // 与 pwsh 版 (mailbox.ps1) 同协议、同配置、同命令, 可互换混用:
6
+ // node mailbox.mjs init --id agent-a --root D:/Downloads/Agent/.mailbox
7
+ // node mailbox.mjs send --to agent-b --topic hello --payload '{"x":1}'
8
+ // node mailbox.mjs recv --format table
9
+ // node mailbox.mjs wait --timeout 60
10
+ // node mailbox.mjs poll --interval 2 --handlers ./handlers.mjs
11
+ // node mailbox.mjs clean --ttl-hours 24
12
+ // node mailbox.mjs status
13
+ //
14
+ // 配置优先级: 参数 > 环境变量 (MAILBOX_CONFIG/ID/ROOT/INTERVAL/TIMEOUT) > 配置文件 > 默认
15
+ // 消息格式: { id, from, to, type, topic, payload, ts, reply_to }
16
+ // ============================================================================
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
19
+ import { join, dirname, basename, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const DEFAULT_CONFIG = join(__dirname, "mailbox.config.json");
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // 参数解析: --key value / --flag
27
+ // ---------------------------------------------------------------------------
28
+ function parseArgs(argv) {
29
+ const args = { _: [] };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a.startsWith("--")) {
33
+ const key = a.slice(2);
34
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
35
+ args[key] = argv[++i];
36
+ } else {
37
+ args[key] = true;
38
+ }
39
+ } else {
40
+ args._.push(a);
41
+ }
42
+ }
43
+ return args;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // 配置解析
48
+ // ---------------------------------------------------------------------------
49
+ function getConfig(args) {
50
+ const cfg = {
51
+ identity: "",
52
+ layout: "root",
53
+ root: "",
54
+ dirs: {},
55
+ participants: [],
56
+ intervalSec: 2,
57
+ timeoutSec: 0,
58
+ seenFile: "",
59
+ patchRoot: "",
60
+ };
61
+ let configPath = args.config || process.env.MAILBOX_CONFIG || DEFAULT_CONFIG;
62
+ if (existsSync(configPath)) {
63
+ try {
64
+ Object.assign(cfg, JSON.parse(readFileSync(configPath, "utf-8")));
65
+ } catch (e) {
66
+ console.warn(`[mailbox] 读取配置失败: ${configPath} (${e.message})`);
67
+ }
68
+ }
69
+ if (process.env.MAILBOX_ID) cfg.identity = process.env.MAILBOX_ID;
70
+ if (process.env.MAILBOX_ROOT) { cfg.root = process.env.MAILBOX_ROOT; cfg.layout = "root"; }
71
+ if (process.env.MAILBOX_INTERVAL) cfg.intervalSec = Number(process.env.MAILBOX_INTERVAL);
72
+ if (process.env.MAILBOX_TIMEOUT) cfg.timeoutSec = Number(process.env.MAILBOX_TIMEOUT);
73
+ if (args.identity) cfg.identity = args.identity;
74
+ if (args.root) { cfg.root = args.root; cfg.layout = "root"; }
75
+ if (args.interval !== undefined) cfg.intervalSec = Number(args.interval);
76
+ if (args.timeout !== undefined) cfg.timeoutSec = Number(args.timeout);
77
+ return { cfg, configPath };
78
+ }
79
+
80
+ function resolveDirs(cfg) {
81
+ if (cfg.layout === "dirs") {
82
+ if (!cfg.dirs || !cfg.dirs[cfg.identity]) {
83
+ throw new Error(`layout=dirs 但配置缺少 identity '${cfg.identity}' 的目录映射`);
84
+ }
85
+ const out = cfg.dirs[cfg.identity];
86
+ const inDirs = [...new Set(Object.entries(cfg.dirs).filter(([k]) => k !== cfg.identity).map(([, v]) => v))];
87
+ return { out, in: inDirs };
88
+ }
89
+ if (!cfg.root) throw new Error("layout=root 需要配置 root");
90
+ const out = join(cfg.root, cfg.identity);
91
+ let participants = Array.isArray(cfg.participants) && cfg.participants.length > 0 ? cfg.participants : [];
92
+ if (participants.length === 0 && existsSync(cfg.root)) {
93
+ participants = readdirSync(cfg.root, { withFileTypes: true })
94
+ .filter((d) => d.isDirectory())
95
+ .map((d) => d.name);
96
+ }
97
+ const inDirs = [...new Set(participants.filter((p) => p !== cfg.identity).map((p) => join(cfg.root, p)))];
98
+ return { out, in: inDirs };
99
+ }
100
+
101
+ function seenFileOf(cfg) {
102
+ if (cfg.seenFile) return cfg.seenFile;
103
+ return join(resolveDirs(cfg).out, ".seen.json");
104
+ }
105
+
106
+ function loadSeen(cfg) {
107
+ const f = seenFileOf(cfg);
108
+ if (!existsSync(f)) return [];
109
+ try {
110
+ // pwsh 旧版本可能把单元素 seen 写成裸字符串 "id", 归一化为数组
111
+ const v = JSON.parse(readFileSync(f, "utf-8"));
112
+ return Array.isArray(v) ? v : [v];
113
+ } catch { return []; }
114
+ }
115
+
116
+ function saveSeen(cfg, seen) {
117
+ const f = seenFileOf(cfg);
118
+ mkdirSync(dirname(f), { recursive: true });
119
+ writeFileSync(f, JSON.stringify([...new Set(seen)]));
120
+ }
121
+
122
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // 命令实现
126
+ // ---------------------------------------------------------------------------
127
+ function cmdInit(cfg, configPath, args) {
128
+ const out = {
129
+ identity: cfg.identity,
130
+ layout: cfg.layout,
131
+ root: cfg.root,
132
+ dirs: cfg.dirs,
133
+ participants: cfg.participants,
134
+ intervalSec: cfg.intervalSec,
135
+ timeoutSec: cfg.timeoutSec,
136
+ seenFile: cfg.seenFile,
137
+ patchRoot: cfg.patchRoot,
138
+ };
139
+ writeFileSync(configPath, JSON.stringify(out, null, 2) + "\n", "utf-8");
140
+ console.log(`已生成配置: ${configPath}`);
141
+ console.log(JSON.stringify(out, null, 2));
142
+ }
143
+
144
+ function cmdSend(cfg, args) {
145
+ if (!args.to) throw new Error("send 需要 --to <id|all>");
146
+ let payload = {};
147
+ if (args.payload) {
148
+ try { payload = JSON.parse(args.payload); } catch { throw new Error(`payload 不是合法 JSON: ${args.payload}`); }
149
+ }
150
+ const dirs = resolveDirs(cfg);
151
+ mkdirSync(dirs.out, { recursive: true });
152
+ const id = `${ts()}${rand4()}-${rand4()}`;
153
+ const msg = {
154
+ id, from: cfg.identity, to: args.to,
155
+ type: args.type || "notify",
156
+ topic: args.topic || "",
157
+ payload,
158
+ ts: Date.now(),
159
+ reply_to: args.replyTo || "",
160
+ };
161
+ writeFileSync(join(dirs.out, `msg_${id}.json`), JSON.stringify(msg) + "\n", "utf-8");
162
+ console.log(`sent ${id} (${new Date().toTimeString().slice(0, 8)})`);
163
+ }
164
+
165
+ function cmdRecv(cfg, args) {
166
+ const msgs = recvNew(cfg, true);
167
+ if (msgs.length === 0) { console.log("(无新消息)"); return; }
168
+ if (args.format === "json") {
169
+ for (const m of msgs) console.log(JSON.stringify(m));
170
+ } else {
171
+ for (const m of msgs) {
172
+ const p = m.payload && Object.keys(m.payload).length ? JSON.stringify(m.payload) : "";
173
+ console.log(`[${m.from} -> ${m.to}] ${m.type} topic=${m.topic} id=${m.id}`);
174
+ if (m.reply_to) console.log(` reply_to=${m.reply_to} ts=${m.ts}`);
175
+ if (p) console.log(` payload: ${p}`);
176
+ }
177
+ }
178
+ }
179
+
180
+ function recvNew(cfg, markSeen) {
181
+ const seen = loadSeen(cfg);
182
+ const dirs = resolveDirs(cfg);
183
+ const fresh = [];
184
+ for (const dir of dirs.in) {
185
+ if (!existsSync(dir)) continue;
186
+ for (const f of readdirSync(dir).filter((f) => f.startsWith("msg_") && f.endsWith(".json")).sort()) {
187
+ try {
188
+ const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
189
+ if ((m.to === cfg.identity || m.to === "all") && !seen.includes(m.id)) {
190
+ fresh.push(m);
191
+ if (markSeen) seen.push(m.id);
192
+ }
193
+ } catch { /* 跳过损坏消息 */ }
194
+ }
195
+ }
196
+ if (markSeen) saveSeen(cfg, seen);
197
+ return fresh;
198
+ }
199
+
200
+ async function cmdWait(cfg, args) {
201
+ const timeoutSec = cfg.timeoutSec;
202
+ const started = Date.now();
203
+ for (;;) {
204
+ const msgs = recvNew(cfg, true);
205
+ if (msgs.length > 0) {
206
+ console.log(`=== NEW MESSAGES: ${msgs.length} ===`);
207
+ for (const m of msgs) console.log(JSON.stringify(m));
208
+ console.log("=== WAKE-UP (exit 0) ===");
209
+ process.exit(0);
210
+ }
211
+ if (timeoutSec > 0 && (Date.now() - started) / 1000 >= timeoutSec) {
212
+ console.log(`TIMEOUT after ${timeoutSec}s, no new messages`);
213
+ process.exit(0);
214
+ }
215
+ await sleep(cfg.intervalSec * 1000);
216
+ }
217
+ }
218
+
219
+ async function cmdPoll(cfg, args) {
220
+ let handler = null;
221
+ if (args.handlers) {
222
+ const p = resolve(args.handlers);
223
+ handler = await import("file://" + p.replace(/\\/g, "/"));
224
+ console.log(`已加载 handlers: ${p}`);
225
+ }
226
+ console.log(`poll 启动 (identity=${cfg.identity} 每 ${cfg.intervalSec}s). Ctrl+C 退出`);
227
+ for (;;) {
228
+ try {
229
+ for (const m of recvNew(cfg, true)) {
230
+ console.log(`[收到] from=${m.from} type=${m.type} topic=${m.topic} id=${m.id}`);
231
+ let handled = false;
232
+ if (handler && typeof handler.handle === "function") {
233
+ handled = await handler.handle(m, { cfg, send: (o) => sendFrom(cfg, { ...o, from: cfg.identity }) });
234
+ }
235
+ if (!handled) {
236
+ if (m.type === "request") {
237
+ sendFrom(cfg, { to: m.from, type: "response", topic: m.topic, payload: { echo: m.payload, from: cfg.identity }, replyTo: m.id });
238
+ console.log(` → 已回 response (reply_to=${m.id})`);
239
+ } else {
240
+ console.log(JSON.stringify(m));
241
+ }
242
+ }
243
+ try { removeMsg(cfg, m.id, true); } catch { /* 权限不足则跳过 */ }
244
+ }
245
+ } catch (e) {
246
+ console.warn(`轮询异常: ${e.message}`);
247
+ }
248
+ await sleep(cfg.intervalSec * 1000);
249
+ }
250
+ }
251
+
252
+ function sendFrom(cfg, { to, type = "notify", topic = "", payload = {}, replyTo = "" }) {
253
+ const dirs = resolveDirs(cfg);
254
+ mkdirSync(dirs.out, { recursive: true });
255
+ const id = `${ts()}${rand4()}-${rand4()}`;
256
+ const msg = { id, from: cfg.identity, to, type, topic, payload, ts: Date.now(), reply_to: replyTo };
257
+ writeFileSync(join(dirs.out, `msg_${id}.json`), JSON.stringify(msg) + "\n", "utf-8");
258
+ return id;
259
+ }
260
+
261
+ function removeMsg(cfg, id, inbox) {
262
+ const dirs = resolveDirs(cfg);
263
+ const targets = inbox ? dirs.in : [dirs.out];
264
+ for (const dir of targets) {
265
+ if (!existsSync(dir)) continue;
266
+ for (const f of readdirSync(dir)) {
267
+ if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
268
+ try {
269
+ const m = JSON.parse(readFileSync(join(dir, f), "utf-8"));
270
+ if (m.id === id) { rmSync(join(dir, f), { force: true }); return; }
271
+ } catch { /* 跳过 */ }
272
+ }
273
+ }
274
+ }
275
+
276
+ function cmdClean(cfg, args) {
277
+ const ttlHours = args.ttlHours !== undefined ? Number(args.ttlHours) : 24;
278
+ const dryRun = !!args.dryRun;
279
+ const dirs = resolveDirs(cfg);
280
+ if (!existsSync(dirs.out)) { console.log(`clean: 0 条 (目录不存在)`); return; }
281
+ const cutoff = Date.now() - ttlHours * 3600 * 1000;
282
+ let removed = 0;
283
+ for (const f of readdirSync(dirs.out)) {
284
+ if (!f.startsWith("msg_") || !f.endsWith(".json")) continue;
285
+ const p = join(dirs.out, f);
286
+ try {
287
+ if (statSync(p).mtimeMs < cutoff) {
288
+ if (!dryRun) rmSync(p, { force: true });
289
+ removed++;
290
+ }
291
+ } catch { /* 跳过 */ }
292
+ }
293
+ console.log(`clean: ${dryRun ? "dry-run" : "已删除"} ${removed} 条过期消息 (TtlHours=${ttlHours})`);
294
+ }
295
+
296
+ function cmdStatus(cfg) {
297
+ const dirs = resolveDirs(cfg);
298
+ const seen = loadSeen(cfg).length;
299
+ const outCount = existsSync(dirs.out) ? readdirSync(dirs.out).filter((f) => f.startsWith("msg_")).length : 0;
300
+ console.log(`身份: ${cfg.identity} layout=${cfg.layout}`);
301
+ console.log(`写: ${dirs.out} (消息 ${outCount})`);
302
+ console.log(`seen: ${seen} 条`);
303
+ for (const d of dirs.in) {
304
+ const n = existsSync(d) ? readdirSync(d).filter((f) => f.startsWith("msg_")).length : 0;
305
+ console.log(`读: ${d} (消息 ${n})`);
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // 工具
311
+ // ---------------------------------------------------------------------------
312
+ function ts() {
313
+ const d = new Date();
314
+ const p = (n) => String(n).padStart(2, "0");
315
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
316
+ }
317
+ function rand4() {
318
+ return Math.random().toString(16).slice(2, 6).padEnd(4, "0");
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // 入口
323
+ // ---------------------------------------------------------------------------
324
+ const args = parseArgs(process.argv.slice(2));
325
+ const command = args._[0] || "status";
326
+ const { cfg, configPath } = getConfig(args);
327
+
328
+ try {
329
+ switch (command) {
330
+ case "init": cmdInit(cfg, configPath, args); break;
331
+ case "send": cmdSend(cfg, args); break;
332
+ case "recv": cmdRecv(cfg, args); break;
333
+ case "wait": await cmdWait(cfg, args); break;
334
+ case "poll": await cmdPoll(cfg, args); break;
335
+ case "clean": cmdClean(cfg, args); break;
336
+ case "status": cmdStatus(cfg); break;
337
+ default:
338
+ console.error(`未知命令: ${command} (可用: init/send/recv/wait/poll/clean/status)`);
339
+ process.exit(1);
340
+ }
341
+ } catch (e) {
342
+ console.error(`[mailbox] ${e.message}`);
343
+ process.exit(1);
344
+ }
@@ -0,0 +1,218 @@
1
+ # ============================================================================
2
+ # mailbox.ps1 — 通用跨会话文件信箱 CLI v1
3
+ #
4
+ # 用法示例:
5
+ # .\mailbox.ps1 init -Id agent-a -Root D:/Downloads/Agent/.mailbox
6
+ # .\mailbox.ps1 send -To agent-b -Topic hello -Payload '{"x":1}'
7
+ # .\mailbox.ps1 recv -Format table
8
+ # .\mailbox.ps1 wait -Timeout 60 # 新消息即退出 (exit 0, 唤醒 agent)
9
+ # .\mailbox.ps1 poll -Interval 2 -Handlers .\examples\handlers.patch.ps1
10
+ # .\mailbox.ps1 clean -TtlHours 24
11
+ # .\mailbox.ps1 status
12
+ # .\mailbox.ps1 sessions # 会话目录 (注册表: 身份/别名/在线)
13
+ #
14
+ # 配置优先级: 参数 > 环境变量 (MAILBOX_CONFIG/ID/ROOT/INTERVAL/TIMEOUT) > 配置文件 > 默认
15
+ # ============================================================================
16
+ [CmdletBinding()]
17
+ param(
18
+ [Parameter(Position = 0)]
19
+ [ValidateSet("init","send","recv","wait","poll","clean","status","sessions")]
20
+ [string]$Command = "status",
21
+
22
+ [string]$Config = "",
23
+ [string]$Identity = "",
24
+ [string]$Root = "",
25
+
26
+ # send
27
+ [string]$To = "",
28
+ [ValidateSet("request","response","notify","reply")]
29
+ [string]$Type = "notify",
30
+ [string]$Topic = "",
31
+ [string]$Payload = "",
32
+ [string]$ReplyTo = "",
33
+
34
+ # recv
35
+ [ValidateSet("table","json")]
36
+ [string]$Format = "table",
37
+
38
+ # wait / poll
39
+ [int]$Timeout = -1,
40
+ [int]$Interval = -1,
41
+ [string]$Handlers = "",
42
+
43
+ # clean
44
+ [int]$TtlHours = 24,
45
+ [switch]$DryRun,
46
+
47
+ # sessions
48
+ [int]$PresenceWindow = 300
49
+ )
50
+
51
+ $ErrorActionPreference = "Stop"
52
+ Import-Module (Join-Path $PSScriptRoot "mailbox.psm1") -Force
53
+
54
+ $cfg = Get-MailboxConfig -ConfigPath $Config
55
+ if ($Identity) { $cfg.identity = $Identity }
56
+ if ($Root) { $cfg.root = $Root; $cfg.layout = "root" }
57
+ if (-not $cfg.identity) { throw "未指定身份: 请用 -Identity / MAILBOX_ID / 配置文件 identity" }
58
+ if ($Interval -ge 0) { $cfg.intervalSec = $Interval }
59
+ if ($Timeout -ge 0) { $cfg.timeoutSec = $Timeout }
60
+
61
+ switch ($Command) {
62
+ "init" {
63
+ $out = [ordered]@{
64
+ identity = $cfg.identity
65
+ layout = "root"
66
+ root = $cfg.root
67
+ dirs = $cfg.dirs
68
+ participants = @()
69
+ intervalSec = $cfg.intervalSec
70
+ timeoutSec = $cfg.timeoutSec
71
+ seenFile = $cfg.seenFile
72
+ patchRoot = $cfg.patchRoot
73
+ }
74
+ $cfgPath = if ($Config) { $Config } else { Join-Path $PSScriptRoot "mailbox.config.json" }
75
+ $out | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $cfgPath -Encoding UTF8
76
+ Write-Host "已生成配置: $cfgPath"
77
+ Write-Host ($out | ConvertTo-Json -Depth 6)
78
+ }
79
+
80
+ "send" {
81
+ if (-not $To) { throw "send 需要 -To <id|alias|all>" }
82
+ $payloadObj = @{}
83
+ if ($Payload) {
84
+ try { $payloadObj = $Payload | ConvertFrom-Json } catch { throw "payload 不是合法 JSON: $Payload" }
85
+ }
86
+ # 别名解析: to 命中注册表 alias / 完整 sessionId → 换成对应 identity
87
+ if ($To -ne "all" -and $cfg.root) {
88
+ $regDir = Join-Path $cfg.root "_sessions"
89
+ if (Test-Path -LiteralPath $regDir) {
90
+ foreach ($f in Get-ChildItem -LiteralPath $regDir -Filter "*.json" -ErrorAction SilentlyContinue) {
91
+ try {
92
+ $r = $f | Get-Content -Raw | ConvertFrom-Json
93
+ if (($r.alias -and $r.alias -eq $To) -or ($r.sessionId -and $r.sessionId -eq $To)) {
94
+ if ($r.identity) { $To = $r.identity }
95
+ break
96
+ }
97
+ } catch { }
98
+ }
99
+ }
100
+ }
101
+ $id = Send-Mailbox -Cfg $cfg -To $To -Type $Type -Topic $Topic -Payload $payloadObj -ReplyTo $ReplyTo
102
+ Write-Output "sent $id ($(Get-Date -Format 'HH:mm:ss'))"
103
+ }
104
+
105
+ "recv" {
106
+ $msgs = @(Recv-Mailbox -Cfg $cfg)
107
+ if ($msgs.Count -eq 0) { Write-Output "(无新消息)"; break }
108
+ if ($Format -eq "json") {
109
+ $msgs | ForEach-Object { Write-Output ($_ | ConvertTo-Json -Depth 8 -Compress) }
110
+ } else {
111
+ $msgs | ForEach-Object {
112
+ $p = if ($_.payload) { ($_ | ConvertTo-Json -Depth 3 -Compress) } else { "" }
113
+ Write-Host "[$($_.from) -> $($_.to)] $($_.type) topic=$($_.topic) id=$($_.id)"
114
+ Write-Host " reply_to=$($_.reply_to) ts=$($_.ts)"
115
+ if ($p) { Write-Host " payload: $p" }
116
+ }
117
+ }
118
+ }
119
+
120
+ "wait" {
121
+ # 事件唤醒: 检测到新消息 → 打印 → exit 0 (DSH 后台 job 完成即通知 agent)
122
+ $started = Get-Date
123
+ while ($true) {
124
+ $msgs = @(Recv-Mailbox -Cfg $cfg)
125
+ if ($msgs.Count -gt 0) {
126
+ Write-Host "=== NEW MESSAGES: $($msgs.Count) ==="
127
+ $msgs | ForEach-Object { Write-Host ($_ | ConvertTo-Json -Depth 8 -Compress) }
128
+ Write-Host "=== WAKE-UP (exit 0) ==="
129
+ exit 0
130
+ }
131
+ if ($cfg.timeoutSec -gt 0 -and ((Get-Date) - $started).TotalSeconds -ge $cfg.timeoutSec) {
132
+ Write-Host "TIMEOUT after $($cfg.timeoutSec)s, no new messages"
133
+ exit 0
134
+ }
135
+ Start-Sleep -Seconds $cfg.intervalSec
136
+ }
137
+ }
138
+
139
+ "poll" {
140
+ # 常驻轮询: 默认 request→echo response, notify/reply→打印
141
+ # -Handlers <ps1>: dot-source 后若定义 Handle-Message($Msg, $Ctx) 则调用
142
+ if ($Handlers) {
143
+ $handlersPath = if (Test-Path -LiteralPath $Handlers) { (Resolve-Path $Handlers).Path } else { $Handlers }
144
+ . $handlersPath
145
+ Write-Host "已加载 handlers: $handlersPath"
146
+ }
147
+ Write-Host "poll 启动 (identity=$($cfg.identity) 每 $($cfg.intervalSec)s). Ctrl+C 退出"
148
+ while ($true) {
149
+ try {
150
+ $msgs = @(Recv-Mailbox -Cfg $cfg)
151
+ foreach ($m in $msgs) {
152
+ Write-Host "[收到] from=$($m.from) type=$($m.type) topic=$($m.topic) id=$($m.id)"
153
+ $handled = $false
154
+ if ($Handlers -and (Get-Command Handle-Message -ErrorAction SilentlyContinue)) {
155
+ $handled = Handle-Message -Msg $m -Ctx @{ Cfg = $cfg }
156
+ }
157
+ if (-not $handled) {
158
+ if ($m.type -eq "request") {
159
+ # 默认: 自动回 response (echo)
160
+ Send-Mailbox -Cfg $cfg -To $m.from -Type "response" -Topic $m.topic -ReplyTo $m.id `
161
+ -Payload @{ echo = $m.payload; from = $cfg.identity }
162
+ Write-Host " → 已回 response (reply_to=$($m.id))"
163
+ } else {
164
+ Write-Host ($m | ConvertTo-Json -Depth 6 -Compress)
165
+ }
166
+ }
167
+ try { Remove-MailboxMsg -Cfg $cfg -Id $m.id -InInbox } catch { }
168
+ }
169
+ } catch {
170
+ Write-Host "轮询异常: $($_.Exception.Message)" -ForegroundColor Yellow
171
+ }
172
+ Start-Sleep -Seconds $cfg.intervalSec
173
+ }
174
+ }
175
+
176
+ "clean" {
177
+ $removed = Clear-MailboxTTL -Cfg $cfg -TtlHours $TtlHours -DryRun:$DryRun
178
+ $mode = if ($DryRun) { "dry-run" } else { "已删除" }
179
+ Write-Output "clean: $mode $removed 条过期消息 (TtlHours=$TtlHours, outDir=$((Resolve-MailboxDirs $cfg).Out))"
180
+ }
181
+
182
+ "status" {
183
+ $s = Get-MailboxStatus $cfg
184
+ Write-Host "身份: $($s.identity) layout=$($s.layout)"
185
+ Write-Host "写: $($s.outDir) (消息 $($s.outCount))"
186
+ Write-Host "seen: $($s.seen) 条"
187
+ foreach ($i in $s.inboxes) {
188
+ Write-Host "读: $($i.Dir) (消息 $($i.MsgCount))"
189
+ }
190
+ }
191
+
192
+ "sessions" {
193
+ # 会话目录: 读 <root>/_sessions/*.json (注册表心跳), 按 lastSeen 倒序
194
+ # 注: 只用实例方法与算术, 兼容只读沙箱 (ConstrainedLanguage) 下无 .NET 静态调用
195
+ if (-not $cfg.root) { throw "sessions 需要 -Root (layout=root)" }
196
+ $regDir = Join-Path $cfg.root "_sessions"
197
+ if (-not (Test-Path -LiteralPath $regDir)) {
198
+ Write-Output "(暂无注册会话: 各会话调用一次 mailbox 工具即自动登记)"
199
+ break
200
+ }
201
+ $nowMs = [int64](((Get-Date).ToUniversalTime().Ticks - 621355968000000000) / 10000)
202
+ $list = @()
203
+ foreach ($f in Get-ChildItem -LiteralPath $regDir -Filter "*.json") {
204
+ try { $list += ($f | Get-Content -Raw | ConvertFrom-Json) } catch { }
205
+ }
206
+ if ($list.Count -eq 0) { Write-Output "(暂无注册会话)"; break }
207
+ foreach ($r in ($list | Sort-Object { $_.lastSeen } -Descending)) {
208
+ $deltaMs = [int64]($nowMs - [int64]$r.lastSeen)
209
+ $online = if ($r.lastSeen -and $deltaMs -lt ($PresenceWindow * 1000)) { "●在线" } else { "○离线" }
210
+ $agoSec = [int]($deltaMs / 1000)
211
+ if ($agoSec -lt 0) { $agoSec = 0 }
212
+ $ago = if ($r.lastSeen) { "$($agoSec)s前" } else { "-" }
213
+ $alias = if ($r.alias) { " ($($r.alias))" } else { "" }
214
+ $title = if ($r.title) { " «$($r.title)»" } else { "" }
215
+ Write-Output "$online $($r.identity)$alias $($r.workspace)$title last=$ago"
216
+ }
217
+ }
218
+ }