@uuaid/pillar 2.0.2 → 2.2.0

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/bin/pillar.mjs CHANGED
@@ -2,32 +2,54 @@
2
2
  /**
3
3
  * pillar — one-command CLI.
4
4
  *
5
- * pillar init [--home ~/.pillar] first-time setup: mint UUAID + keys
5
+ * pillar init [--home ~/.pillar] [--namespace pillar|agentnet|foundation]
6
+ * first-time setup: mint UUAID + keys (default namespace: foundation in 2.2.0)
6
7
  * pillar init --refresh [--directory URL] re-sync carriers + address book (keychain untouched)
8
+ * pillar init --force --confirm <current uuaid> REPLACE the identity (new UUAID); the old one is archived
7
9
  * pillar id [--home ~/.pillar] print our UUAID + PeerId + addrs
8
- * pillar send <recipient> <kind> <json> send a signed envelope
9
- * pillar recv [--home ~/.pillar] [--follow] listen for envelopes and print them
10
- * pillar inbox [--home ~/.pillar] [--unread] [--notify CMD] [--mark-read]
10
+ * pillar send <recipient|name> <kind> <json> [--wait-ack S]
11
+ * send a signed envelope; optionally wait for the recipient's signed ack
12
+ * pillar recv [--home ~/.pillar] [--reader NAME] listen for envelopes and print them
13
+ * pillar inbox [--home ~/.pillar] [--reader NAME] [--unread] [--notify CMD] [--mark-read]
11
14
  * one-shot: pull pending mail, print, exit
15
+ * pillar wait [--reader NAME] [--timeout S] sleep until there is mail, print the count, exit 0 (3 on timeout)
16
+ * pillar readers every reader of this home, its cursor and any live lease
17
+ * pillar status [envelope-id] [--wait S] outbox: carrier acceptance and the recipient's signed receipts
18
+ * pillar receipts [on|off|delivered on|off|read on|off] send signed acks / read receipts (off by default)
19
+ * pillar group create|send|add|rename|leave|accept|show group threads (pillar-group v1)
20
+ * pillar group create --v2 | invite|remove|ext|transfer|list|queue|sync, and any verb
21
+ * naming a pillar-group/2 group signed rosters (src/group-v2-cli.mjs)
22
+ * pillar groups list this home's groups
12
23
  * pillar trust [set|allow|block|unlist|quarantine] recipient consent policy
13
24
  * pillar tier [issue|install|verify] signed volume/size tier grants
14
25
  * pillar peers list currently connected peers
15
26
  *
16
27
  * All commands respect --home to point at an alternate pillar directory,
17
28
  * useful for running multiple pillars on one machine (Zestro dogfood etc.).
29
+ * More than one process reading ONE home: give each its own --reader NAME
30
+ * (or PILLAR_READER) so each has its own cursor; see src/readers.mjs.
18
31
  */
19
32
  import "../src/node-check.mjs"; // must stay the first import: refuses Node < 22 before any dependency loads
20
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
33
+ import { mkdirSync, existsSync, readFileSync, writeFileSync, renameSync } from "node:fs";
21
34
  import { join, dirname } from "node:path";
22
35
  import { homedir } from "node:os";
23
36
  import { argv, exit } from "node:process";
24
- import { Keychain } from "../src/identity/keychain.mjs";
37
+ import { Keychain, mintNamespace, SELF_CERTIFYING_NAMESPACES } from "../src/identity/keychain.mjs";
25
38
  import { Pillar } from "../src/pillar.mjs";
26
- import { loadFleetAddressBook } from "../src/address-book.mjs";
39
+ import { loadFleetAddressBook, inspectFleetFile, resolveRecipient, explainMissingKey } from "../src/address-book.mjs";
27
40
  import { loadTrustPolicy, POLICIES } from "../src/identity/trust.mjs";
28
41
  import { parseLinkOffer, isLinkOfferLive, LINK_GRANT_KINDS } from "../src/net/link-offer.mjs";
29
42
  import { mintDelegationDoc } from "../src/net/delegation.mjs";
30
43
  import { refusalLine } from "../src/net/refusals.mjs";
44
+ import {
45
+ readerKey, processContext, otherLiveLeases, describeProcess, describeReader, initialCursor, handleInOrder,
46
+ LEASE_TTL_MS, LEASE_HEARTBEAT_MS,
47
+ } from "../src/readers.mjs";
48
+ import { extractGroupTag, findGroup, normalizeMembers } from "../src/groups.mjs";
49
+ import { describeOutboundStatus, receiptFor } from "../src/receipts.mjs";
50
+ import { groupCommandRoute, groupRouteLookups, RosterStore } from "../src/groups-v2.mjs";
51
+ import { runGroupV2 } from "../src/group-v2-cli.mjs";
52
+ import { Mailbox } from "../src/mailbox/mailbox.mjs";
31
53
 
32
54
  function parseArgs(argv) {
33
55
  const args = { _: [] };
@@ -52,16 +74,34 @@ function fleetPath(args) {
52
74
  return args.fleet || join(homePath(args), "fleet.json");
53
75
  }
54
76
 
55
- function loadPillar(args) {
77
+ function readConfig(home) {
78
+ const cfgPath = join(home, "config.json");
79
+ if (!existsSync(cfgPath)) return {};
80
+ try { return JSON.parse(readFileSync(cfgPath, "utf-8")); }
81
+ catch (e) {
82
+ console.error(`${cfgPath} is not valid JSON (${e.message}) — fix it, or re-create it with pillar init --refresh --directory <url>`);
83
+ exit(2);
84
+ }
85
+ }
86
+
87
+ // Receipts are opt-in: config.json `receipts: { delivered, read }` (pillar
88
+ // receipts on|off), overridden per run by --ack (delivery acks on) and
89
+ // --no-receipts (both off).
90
+ function receiptsFor(args, cfg) {
91
+ if (args["no-receipts"]) return { delivered: false, read: false };
92
+ const r = cfg.receipts ?? {};
93
+ return { delivered: !!r.delivered || !!args.ack, read: !!r.read };
94
+ }
95
+
96
+ function loadPillar(args, { reader = null } = {}) {
56
97
  const home = homePath(args);
57
98
  const kcPath = join(home, "keychain.json");
58
99
  const mbPath = join(home, "mailbox.db");
59
- const cfgPath = join(home, "config.json");
60
100
  if (!existsSync(kcPath)) {
61
101
  console.error(`no pillar found at ${home}. run: pillar init`);
62
102
  exit(2);
63
103
  }
64
- const cfg = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf-8")) : {};
104
+ const cfg = readConfig(home);
65
105
  const kc = new Keychain(kcPath);
66
106
  kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
67
107
  const carriers = args.carrier
@@ -91,9 +131,69 @@ function loadPillar(args) {
91
131
  enableLibp2p: !(args["carrier-only"] || cfg.carrierOnly === true),
92
132
  trust,
93
133
  tierGrant,
134
+ reader,
135
+ receipts: receiptsFor(args, cfg),
94
136
  });
95
137
  }
96
138
 
139
+ /**
140
+ * Load the address book into `p`, printing every problem with it on stderr
141
+ * (once), and return the inspection so a later error can say exactly why a
142
+ * contact is unusable instead of "no public key known".
143
+ */
144
+ function loadContacts(args, p) {
145
+ const path = fleetPath(args);
146
+ const info = inspectFleetFile(path);
147
+ const n = loadFleetAddressBook(path, p, { onProblem: (msg) => console.error(`warning: ${msg}`) });
148
+ return { info, n, path };
149
+ }
150
+
151
+ // ── readers ───────────────────────────────────────────────────────────────────
152
+
153
+ function readerFor(args) {
154
+ const name = args.reader ?? process.env.PILLAR_READER;
155
+ try { return readerKey(name); }
156
+ catch (e) { console.error(e.message); exit(2); }
157
+ }
158
+
159
+ /**
160
+ * Tell this reader, on stderr, about any live process that will pull its
161
+ * mail first. Unnamed readers share a view with every other process, so any
162
+ * live consumer matters; a named reader only collides with its own name.
163
+ * `showsUnread`: an unnamed reader printing the unread backlog (--unread)
164
+ * loses nothing to a recv (recv never marks mail read), so it is not warned.
165
+ */
166
+ function warnAboutLiveReaders(p, rk, ctx, { showsUnread = false } = {}) {
167
+ const leases = otherLiveLeases(p.mailbox, ctx);
168
+ const clash = rk.named ? leases.filter((l) => l.reader === rk.key) : (showsUnread ? [] : leases);
169
+ for (const l of clash) {
170
+ if (rk.named) {
171
+ console.error(`warning: reader "${rk.key}" is also held by ${describeProcess(l)}. Two processes under one reader name share one cursor, so each sees only part of the mail. Give each its own --reader NAME.`);
172
+ } else {
173
+ console.error(`warning: ${describeProcess(l)} is reading this home${l.reader.startsWith("ctx:") ? "" : ` as reader "${l.reader}"`}. It pulls mail as it arrives, so that mail will not be listed as new here. Give each reader its own cursor with --reader NAME (or PILLAR_READER).`);
174
+ }
175
+ }
176
+ }
177
+
178
+ function inboxLine(env, payload, extra = {}) {
179
+ const line = { ...extra, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload };
180
+ const tag = extractGroupTag(payload);
181
+ if (tag) {
182
+ line.group = tag.name;
183
+ line.groupId = tag.id;
184
+ if (tag.msgId) line.msgId = tag.msgId;
185
+ if (tag.op !== "message") line.op = tag.op;
186
+ }
187
+ return line;
188
+ }
189
+
190
+ function viewOfRow(p, row, decrypt) {
191
+ const env = JSON.parse(row.envelope_json);
192
+ let payload;
193
+ try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
194
+ return { env, payload, seq: row.seq, groupId: row.group_id ?? null, pulledBy: row.pulled_by ?? null };
195
+ }
196
+
97
197
  function loadGroups(args) {
98
198
  // groups.json lives next to fleet.json by default:
99
199
  // { "groups": { "<name>": { "members": ["uuaid:...", ...] } } }
@@ -186,14 +286,26 @@ async function cmdInit(args) {
186
286
  }
187
287
 
188
288
  const kcPath = join(home, "keychain.json");
189
- if (existsSync(kcPath) && !args.force) {
190
- console.error(`keychain already exists at ${kcPath}. use --force to overwrite.`);
191
- exit(1);
289
+ let rotated = null;
290
+ if (existsSync(kcPath)) {
291
+ const old = new Keychain(kcPath).publicView();
292
+ if (!args.force) {
293
+ console.error(`this home already has an identity: ${old?.uuaid ?? kcPath}`);
294
+ console.error(` re-sync carriers and contacts, keeping it: pillar init --refresh --directory <url> --home ${home}`);
295
+ console.error(` replace it with a NEW identity (new UUAID): pillar init --force --confirm <current uuaid> --home ${home}`);
296
+ exit(1);
297
+ }
298
+ rotated = await confirmAndArchiveIdentity(home, old, args);
192
299
  }
300
+ let uuaidNamespace;
301
+ try { uuaidNamespace = mintNamespace(args.namespace === undefined ? undefined : args.namespace); }
302
+ catch { console.error(`init: --namespace must be one of ${SELF_CERTIFYING_NAMESPACES.join(", ")}`); exit(1); }
193
303
  const kc = new Keychain(kcPath);
194
- const id = Keychain.generate();
304
+ const id = Keychain.generate({ uuaidNamespace });
195
305
  kc.save(id, { passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
196
- const cfg = {
306
+ // A rotation keeps the home's settings (carriers, receipts, …); only the
307
+ // identity and what is bound to it were archived.
308
+ const cfg = rotated ? readConfig(home) : {
197
309
  listenAddrs: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
198
310
  bootstrapPeers: [],
199
311
  enableRelay: false,
@@ -223,10 +335,70 @@ async function cmdInit(args) {
223
335
  console.log(`initialized pillar at ${home}`);
224
336
  console.log(`uuaid: ${id.uuaid}`);
225
337
  console.log(`publicKey: ${id.publicKeyHex}`);
338
+ if (rotated) {
339
+ console.log(`\nreplaced identity ${rotated.uuaid}`);
340
+ console.log(` archived to ${rotated.dir} (keychain, mailbox, tier grant): the old key still opens the old mail there`);
341
+ console.log(` every contact and directory listing still names the OLD uuaid: send them the new uuaid + publicKey, and re-list it`);
342
+ if (cfg.carriers?.length) console.log(` carriers kept: ${cfg.carriers.join(", ")}`);
343
+ return;
344
+ }
226
345
  console.log(`\nconfig: ${join(home, "config.json")}`);
227
346
  console.log(`edit bootstrapPeers to join a network, or run again with --directory <url> / --carriers a,b to wire seeds.`);
228
347
  }
229
348
 
349
+ /**
350
+ * `init --force` replaces an identity, which silently breaks every contact
351
+ * that knows the old UUAID. So it must be confirmed, and it is never lossy:
352
+ *
353
+ * - interactive: type the current UUAID's last 8 characters;
354
+ * non-interactive: --confirm <current uuaid, or its last 8 characters>
355
+ * - refused while a live `pillar recv` holds this home (it would keep
356
+ * writing into the archived mailbox)
357
+ * - everything bound to the old identity (keychain, mailbox, tier grant,
358
+ * runtime file) is MOVED to <home>/rotated/<time>-<old id>/, not deleted
359
+ *
360
+ * Returns { uuaid, dir }.
361
+ */
362
+ async function confirmAndArchiveIdentity(home, old, args) {
363
+ const current = old?.uuaid ?? "";
364
+ const tail = current.replace(/-/g, "").slice(-8);
365
+ const accepted = (v) => typeof v === "string" && (v === current || v.replace(/-/g, "").toLowerCase() === tail.toLowerCase());
366
+ let answer = typeof args.confirm === "string" ? args.confirm : null;
367
+ if (answer === null && process.stdin.isTTY && process.stderr.isTTY) {
368
+ const { createInterface } = await import("node:readline/promises");
369
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
370
+ console.error(`This REPLACES the identity of ${home}:`);
371
+ console.error(` ${current}`);
372
+ console.error(`with a new key and a new UUAID. Contacts that know the old one will no longer reach you.`);
373
+ answer = (await rl.question(`Type the last 8 characters of the current UUAID (${tail}) to continue: `)).trim();
374
+ rl.close();
375
+ }
376
+ if (!accepted(answer)) {
377
+ console.error(answer === null
378
+ ? `init --force replaces ${current} with a NEW identity. Confirm it: pillar init --force --confirm ${current}`
379
+ : `confirmation "${answer}" does not match ${current}; nothing was changed`);
380
+ exit(1);
381
+ }
382
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
383
+ const mbPath = join(home, "mailbox.db");
384
+ if (existsSync(mbPath)) {
385
+ const mb = new Mailbox(mbPath);
386
+ const live = otherLiveLeases(mb);
387
+ mb.close();
388
+ if (live.length) {
389
+ console.error(`refusing to replace the identity while ${live.map(describeProcess).join("; ")} is reading this home — stop it first`);
390
+ exit(1);
391
+ }
392
+ }
393
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
394
+ const dir = join(home, "rotated", `${stamp}-${(current.split(":")[3] ?? "unknown").slice(0, 8)}`);
395
+ mkdirSync(dir, { recursive: true });
396
+ for (const f of ["keychain.json", "mailbox.db", "mailbox.db-wal", "mailbox.db-shm", "tier-grant.json", "runtime.json"]) {
397
+ if (existsSync(join(home, f))) renameSync(join(home, f), join(dir, f));
398
+ }
399
+ return { uuaid: current, dir };
400
+ }
401
+
230
402
  async function cmdId(args) {
231
403
  const p = loadPillar(args);
232
404
  await p.start();
@@ -244,31 +416,85 @@ async function cmdId(args) {
244
416
  }
245
417
 
246
418
  async function cmdSend(args) {
247
- const [recipient, kind, jsonStr] = args._.slice(1);
248
- if (!recipient || !kind) {
249
- console.error("usage: pillar send <recipient-uuaid> <kind> [json-payload] [--fleet FLEET.json]");
419
+ const [recipientArg, kind, jsonStr] = args._.slice(1);
420
+ if (!recipientArg || !kind) {
421
+ console.error("usage: pillar send <recipient-uuaid|contact-name> <kind> [json-payload] [--to-pubkey HEX] [--wait-ack SECONDS] [--fleet FLEET.json]");
250
422
  exit(2);
251
423
  }
252
- const payload = jsonStr ? JSON.parse(jsonStr) : {};
424
+ let payload = {};
425
+ if (jsonStr) {
426
+ try { payload = JSON.parse(jsonStr); }
427
+ catch (e) { console.error(`the payload is not valid JSON (${e.message}): ${jsonStr.slice(0, 120)}`); exit(2); }
428
+ }
253
429
  const p = loadPillar(args);
254
430
  await p.start();
255
- const n = loadFleetAddressBook(fleetPath(args), p);
431
+ const { info, n } = loadContacts(args, p);
256
432
  if (n > 0) console.log(`address book: ${n} peers`);
433
+ let recipient;
434
+ try { recipient = resolveRecipient(recipientArg, info).uuaid; }
435
+ catch (e) { await p.stop(); console.error(e.message); exit(2); }
436
+ const toKey = typeof args["to-pubkey"] === "string" ? args["to-pubkey"] : undefined;
437
+ // Say WHY there is no key before the library says only that there is none
438
+ // (a malformed contacts file used to surface as "no public key known").
439
+ if (!toKey && !p.addressBook?.get(recipient)?.publicKey && !p.node) {
440
+ await p.stop();
441
+ console.error(explainMissingKey(recipient, info));
442
+ exit(1);
443
+ }
257
444
  try {
258
445
  const { envelope, delivery } = await p.send({
259
446
  recipient, kind, payload,
260
- recipientPublicKey: args["to-pubkey"], // explicit key for peers outside fleet.json
447
+ recipientPublicKey: toKey, // explicit key for peers outside fleet.json
261
448
  resolveTimeoutMs: 5000,
262
449
  });
263
450
  console.log(`sent envelope ${envelope.id}`);
264
- console.log(` to: ${recipient}`);
451
+ console.log(` to: ${recipient}${recipient !== recipientArg ? ` (${recipientArg})` : ""}`);
265
452
  console.log(` kind: ${kind}`);
266
453
  if (delivery?.peerId) console.log(` via peerId: ${delivery.peerId}`);
454
+ if (delivery?.carrier) console.log(` accepted by carrier ${delivery.carrier} (seq ${delivery.seq}) — that is the carrier's receipt, not the recipient's`);
455
+ if (args["wait-ack"] !== undefined) {
456
+ const secs = args["wait-ack"] === true ? 60 : Number(args["wait-ack"]);
457
+ if (!Number.isFinite(secs) || secs <= 0) { console.error("--wait-ack takes a number of seconds"); exit(2); }
458
+ const r = await waitForAck(p, envelope.id, recipient, Date.now() + secs * 1000);
459
+ if (r.acked) {
460
+ console.log(` acknowledged: signed by ${recipient} (${r.how}, ${r.at})`);
461
+ } else {
462
+ console.error(` no signed ack from ${recipient} within ${secs}s. The carrier holds the envelope; the recipient has not acknowledged it (acks are opt-in on their side: pillar receipts on).`);
463
+ exit(4);
464
+ }
465
+ }
466
+ } catch (e) {
467
+ if (/no public key known/.test(e.message)) { console.error(explainMissingKey(recipient, info)); exit(1); }
468
+ throw e;
267
469
  } finally {
268
470
  await p.stop();
269
471
  }
270
472
  }
271
473
 
474
+ /**
475
+ * Wait for the recipient's signed ack of `envelopeId` without consuming any
476
+ * other mail: the carriers are PEEKED (nothing stored, no cursor moved), so
477
+ * whoever reads this home next still sees everything as new. A row another
478
+ * process of this home already acked (it pulled the ack) counts too.
479
+ */
480
+ async function waitForAck(p, envelopeId, recipient, deadlineMs) {
481
+ const acked = () => p.mailbox.getOutbound(envelopeId)?.recipient_ack_at ?? null;
482
+ if (acked()) return { acked: true, how: "already recorded", at: acked() };
483
+ if (!p.carrierClient) return { acked: false };
484
+ const hits = await p.peekCarrierInbox({
485
+ deadlineMs,
486
+ stopWhen: () => !!acked(),
487
+ match: (v) => receiptFor(v, envelopeId, recipient, { read: p.receipts.read }) !== null,
488
+ });
489
+ if (hits.length) {
490
+ const h = hits[0];
491
+ const said = receiptFor(h, envelopeId, recipient, { read: p.receipts.read });
492
+ if (said === "delivered") p.mailbox.markOutboundAcked(envelopeId, { from: recipient, ackId: h.id, at: h.createdAt });
493
+ else p.mailbox.markOutboundRead([envelopeId], { at: h.createdAt });
494
+ return { acked: true, how: said === "read" ? "read receipt" : "delivery ack", at: h.createdAt };
495
+ }
496
+ return acked() ? { acked: true, how: "recorded by another reader of this home", at: acked() } : { acked: false };
497
+ }
272
498
 
273
499
  // ── delegate ──────────────────────────────────────────────────────────────────
274
500
  // The desktop-link ceiling enforced by chat-web (browser-chat-session.ts,
@@ -388,42 +614,214 @@ async function cmdSendGroup(args) {
388
614
  const [groupName, kind, jsonStr] = args._.slice(1);
389
615
  if (!groupName || !kind) {
390
616
  console.error("usage: pillar send-group <group> <kind> [json-payload] --fleet FLEET.json [--groups GROUPS.json]");
617
+ console.error(" (or keep the thread in this home: pillar group create / pillar group send)");
391
618
  exit(2);
392
619
  }
393
620
  const { path: gPath, groups } = loadGroups(args);
394
621
  const group = groups[groupName];
395
- if (!group?.members?.length) {
396
- console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"})`);
397
- exit(2);
398
- }
399
622
  const payload = jsonStr ? JSON.parse(jsonStr) : {};
400
623
  const p = loadPillar(args);
401
624
  await p.start();
402
- loadFleetAddressBook(fleetPath(args), p);
625
+ loadContacts(args, p);
403
626
  try {
404
- const { results } = await p.sendGroup({ group: groupName, members: group.members, kind, payload });
405
- let failed = 0;
406
- for (const r of results) {
407
- if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
408
- else { failed++; console.error(` ✗ ${r.member} ${refusalLine(r) ?? r.error}`); }
409
- }
410
- console.log(`group "${groupName}": ${results.length - failed}/${results.length} delivered`);
411
- if (failed) exit(1);
627
+ let spec;
628
+ if (group?.members?.length) {
629
+ spec = { group: groupName, members: group.members };
630
+ } else {
631
+ let g;
632
+ try { g = findGroup(p.mailbox, groupName); }
633
+ catch (e) {
634
+ console.error(`group "${groupName}" not found or empty (looked in ${gPath ?? "no groups file"} and in this home's groups: ${e.message})`);
635
+ exit(2);
636
+ }
637
+ spec = { groupId: g.id };
638
+ }
639
+ const r = await p.sendGroup({ ...spec, kind, payload });
640
+ printGroupResults(r);
412
641
  } finally {
413
642
  await p.stop();
414
643
  }
415
644
  }
416
645
 
646
+ function printGroupResults({ group, groupId, msgId, results }) {
647
+ let failed = 0;
648
+ for (const r of results) {
649
+ if (r.ok) console.log(` ✓ ${r.member} via ${r.via} ${r.envelopeId}`);
650
+ else {
651
+ failed++;
652
+ const why = refusalLine(r) ?? (/no public key known/.test(r.error ?? "")
653
+ ? `no public key known — add it to fleet.json, or ask a member to post in the group (its key hint is then learned)`
654
+ : r.error);
655
+ console.error(` ✗ ${r.member} ${why}`);
656
+ }
657
+ }
658
+ console.log(`group "${group}" ${groupId} msg ${msgId}: ${results.length - failed}/${results.length} accepted by carrier`);
659
+ if (failed) exit(1);
660
+ }
661
+
417
662
  async function cmdGroups(args) {
663
+ const home = homePath(args);
664
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
665
+ const mine = existsSync(join(home, "mailbox.db")) ? (() => {
666
+ const mb = new Mailbox(join(home, "mailbox.db"));
667
+ try { return mb.listGroups(); } finally { mb.close(); }
668
+ })() : [];
418
669
  const { path: gPath, groups } = loadGroups(args);
419
670
  const names = Object.keys(groups);
420
- if (!names.length) { console.log(`no groups defined (${gPath ?? "no groups file"})`); return; }
671
+ if (!mine.length && !names.length) { console.log(`no groups (create one: pillar group create <name> <member>...)`); return; }
672
+ for (const g of mine) {
673
+ const flags = [g.pending ? "PENDING invitation — pillar group accept " + g.id : null, g.left ? "left" : null].filter(Boolean);
674
+ console.log(`${g.id} ${g.name} (${g.members.length} members)${flags.length ? ` [${flags.join(", ")}]` : ""}`);
675
+ for (const m of g.members) console.log(` ${m}`);
676
+ }
421
677
  for (const n of names) {
422
- console.log(`${n} (${groups[n].members.length} members):`);
678
+ console.log(`${n} (${groups[n].members.length} members, from ${gPath}):`);
423
679
  for (const m of groups[n].members) console.log(` ${m}`);
424
680
  }
425
681
  }
426
682
 
683
+ /**
684
+ * pillar group <create|send|add|rename|leave|accept|show> — group threads.
685
+ *
686
+ * The roster lives in this home (mailbox `groups`); every member keeps its
687
+ * own copy and applies the pillar-group v1 rules (src/groups.mjs). Members
688
+ * can be given as UUAIDs or as contact names from fleet.json.
689
+ */
690
+ async function cmdGroup(args) {
691
+ const sub = args._[1];
692
+ const usage = () => {
693
+ console.error(`usage:
694
+ pillar group create <name> <member>... [--text TEXT]
695
+ pillar group send <group> <text> | --json '{...}' [--kind KIND]
696
+ pillar group add <group> <member>...
697
+ pillar group rename <group> <new name>
698
+ pillar group leave <group>
699
+ pillar group accept <group>
700
+ pillar group show <group> [--limit N]
701
+ <group> is a grp-… id or a unique group name; <member> is a uuaid or a fleet.json contact name.`);
702
+ exit(2);
703
+ };
704
+ if (!["create", "send", "add", "rename", "leave", "accept", "show"].includes(sub)) usage();
705
+ args["carrier-only"] = args["carrier-only"] ?? true;
706
+ const p = loadPillar(args);
707
+ await p.start();
708
+ const { info } = loadContacts(args, p);
709
+ const members = (list) => list.map((m) => {
710
+ try { return resolveRecipient(m, info).uuaid; }
711
+ catch (e) { console.error(e.message); exit(2); }
712
+ });
713
+ const group = (ref) => {
714
+ if (!ref) usage();
715
+ try { return findGroup(p.mailbox, ref); }
716
+ catch (e) { console.error(e.message); exit(2); }
717
+ };
718
+ try {
719
+ if (sub === "create") {
720
+ const [name, ...rest] = args._.slice(2);
721
+ if (!name || !rest.length) usage();
722
+ const roster = normalizeMembers([p.uuaid, ...members(rest)]);
723
+ const r = await p.sendGroup({ group: name, members: roster, payload: { text: typeof args.text === "string" ? args.text : "" }, op: "create" });
724
+ printGroupResults(r);
725
+ return;
726
+ }
727
+ if (sub === "send") {
728
+ const g = group(args._[2]);
729
+ let payload;
730
+ if (typeof args.json === "string") {
731
+ try { payload = JSON.parse(args.json); } catch (e) { console.error(`--json is not valid JSON: ${e.message}`); exit(2); }
732
+ } else {
733
+ const text = args._.slice(3).join(" ");
734
+ if (!text) usage();
735
+ payload = { text };
736
+ }
737
+ printGroupResults(await p.sendGroup({ groupId: g.id, kind: typeof args.kind === "string" ? args.kind : "message", payload }));
738
+ return;
739
+ }
740
+ if (sub === "add") {
741
+ const g = group(args._[2]);
742
+ const added = members(args._.slice(3)).filter((m) => !g.members.includes(m));
743
+ if (!added.length) { console.log(`nothing to add: every one of them is already in ${g.id}`); return; }
744
+ p.mailbox.putGroup({ ...g, members: [...g.members, ...added] });
745
+ printGroupResults(await p.sendGroup({ groupId: g.id, payload: { text: added.join(",") }, op: "add" }));
746
+ return;
747
+ }
748
+ if (sub === "rename") {
749
+ const g = group(args._[2]);
750
+ const name = args._.slice(3).join(" ").trim();
751
+ if (!name) usage();
752
+ p.mailbox.putGroup({ ...g, name });
753
+ printGroupResults(await p.sendGroup({ groupId: g.id, payload: { text: "" }, op: "rename" }));
754
+ return;
755
+ }
756
+ if (sub === "leave") {
757
+ const g = group(args._[2]);
758
+ if (g.left) { console.log(`already left ${g.id}`); return; }
759
+ if (!g.pending) {
760
+ const r = await p.sendGroup({ groupId: g.id, payload: { text: "" }, op: "leave" });
761
+ for (const x of r.results) if (!x.ok) console.error(` ✗ ${x.member} ${x.error}`);
762
+ }
763
+ p.mailbox.putGroup({ ...g, left: true, pending: false });
764
+ console.log(`left ${g.id} (${g.name}); its history stays in this home`);
765
+ return;
766
+ }
767
+ if (sub === "accept") {
768
+ const g = group(args._[2]);
769
+ if (!g.pending) { console.log(`${g.id} is not a pending invitation`); return; }
770
+ p.mailbox.putGroup({ ...g, pending: false });
771
+ console.log(`accepted ${g.id} (${g.name}), created by ${g.createdBy}`);
772
+ console.log(`note: messages from members who are not your contacts still follow trust.json (pillar trust allow <uuaid>)`);
773
+ return;
774
+ }
775
+ if (sub === "show") {
776
+ const g = group(args._[2]);
777
+ const limit = Number(args.limit ?? 30);
778
+ const { decrypt } = await import("../src/net/envelope.mjs");
779
+ // A local view: it never pulls from carriers (that would take "new" away
780
+ // from the next inbox run). Pull with pillar inbox first.
781
+ console.log(JSON.stringify({ group: g.id, name: g.name, members: g.members, pending: g.pending, left: g.left, createdBy: g.createdBy, view: "local — pull new mail with pillar inbox" }));
782
+ const items = [];
783
+ for (const row of p.mailbox.groupInbound(g.id, limit)) {
784
+ const { env, payload } = viewOfRow(p, row, decrypt);
785
+ const tag = extractGroupTag(payload);
786
+ items.push({ at: env.createdAt, line: { dir: "in", id: env.id, from: env.sender, kind: env.kind, msgId: tag?.msgId ?? null, op: tag?.op ?? "message", text: typeof payload?.text === "string" ? payload.text : undefined, payload: typeof payload?.text === "string" ? undefined : payload } });
787
+ }
788
+ // Our own messages: one line per group message (its fan-out copies
789
+ // collapsed by msgId). The outbox is sealed to each member, so we hold
790
+ // no plaintext for it — only who it went to and what they signed back.
791
+ const byMsg = new Map();
792
+ for (const row of p.mailbox.groupOutbound(g.id)) {
793
+ const k = row.group_msg_id ?? row.envelope_id;
794
+ const m = byMsg.get(k) ?? { at: row.created_at, to: 0, carrier: 0, acked: 0, read: 0 };
795
+ m.to++; if (row.status === "delivered") m.carrier++; if (row.recipient_ack_at) m.acked++; if (row.recipient_read_at) m.read++;
796
+ byMsg.set(k, m);
797
+ }
798
+ for (const [msgId, m] of byMsg) items.push({ at: m.at, line: { dir: "out", msgId, at: m.at, members: m.to, acceptedByCarrier: m.carrier, acknowledged: m.acked, read: m.read } });
799
+ items.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
800
+ for (const it of items.slice(-limit)) console.log(JSON.stringify(it.line));
801
+ return;
802
+ }
803
+ } finally {
804
+ await p.stop();
805
+ }
806
+ }
807
+
808
+ // `pillar group` runs pillar-group/2 (src/group-v2-cli.mjs) for its own verbs, for
809
+ // create --v2, and for any group the v2 store holds or whose row the v2 sync marked
810
+ // (groupCommandRoute). Everything else runs cmdGroup above, unchanged.
811
+ function wantsGroupV2(args) {
812
+ const home = homePath(args);
813
+ const mbPath = join(home, "mailbox.db");
814
+ const mb = existsSync(mbPath) ? new Mailbox(mbPath) : null;
815
+ try { return groupCommandRoute(args, groupRouteLookups(new RosterStore(home), mb)) === "v2"; }
816
+ finally { mb?.close(); }
817
+ }
818
+
819
+ async function cmdGroupV2(args) {
820
+ args["carrier-only"] = args["carrier-only"] ?? true;
821
+ const code = await runGroupV2(args, { home: homePath(args), openPillar: async () => loadPillar(args) });
822
+ if (code) exit(code);
823
+ }
824
+
427
825
  // Extract human-readable text from a zestro command_result. The server-side
428
826
  // payload fields are unverified — defensively try the common shapes, cap at
429
827
  // 8192 bytes (pillar-responder's MAX_REPLY_BYTES convention).
@@ -438,10 +836,29 @@ function replyTextFromResult(result) {
438
836
  }
439
837
 
440
838
  async function cmdRecv(args) {
441
- const p = loadPillar(args);
839
+ const rk = readerFor(args);
840
+ const ctx = processContext({ command: rk.named ? `pillar recv --reader ${rk.key}` : "pillar recv" });
841
+ // A daemon with no contacts quarantines every fleet message under an
842
+ // allowlist or directory policy and moves the carrier cursor past it. A
843
+ // contacts file that exists but yields nothing at all stops recv instead.
844
+ const fleetInfo = inspectFleetFile(fleetPath(args));
845
+ if (fleetInfo.error) { console.error(`recv: ${fleetInfo.error}`); exit(1); }
846
+ const p = loadPillar(args, { reader: rk.key });
442
847
  await p.start();
443
848
  writeRuntimeFile(p, homePath(args)); // warm handoff: publish live addr for senders
444
- const n = loadFleetAddressBook(fleetPath(args), p);
849
+ const { n } = loadContacts(args, p);
850
+ // Lease: other readers of this home are told a live recv is pulling mail.
851
+ warnAboutLiveReaders(p, rk, ctx);
852
+ const prevReader = p.mailbox.getReader(rk.key);
853
+ // A NEW named reader of a bridged recv starts at the tip: its unread
854
+ // backlog would otherwise reach the bridge as fresh agent turns.
855
+ const bridged = !!(args["zestro-bridge"] || process.env.PILLAR_ZESTRO_URL);
856
+ const start = bridged ? p.mailbox.inboxTipSeq() : initialCursor(p.mailbox);
857
+ p.mailbox.touchReader(rk.key, { ...ctx, cursor: prevReader ? 0 : start });
858
+ const lease = () => p.mailbox.holdLease(rk.key, { ...ctx, ttlMs: LEASE_TTL_MS });
859
+ lease();
860
+ const heartbeat = setInterval(() => { try { lease(); } catch (_e) {} }, LEASE_HEARTBEAT_MS);
861
+ heartbeat.unref?.();
445
862
  console.log(`listening as ${p.uuaid}`);
446
863
  if (p.node) {
447
864
  console.log(`peerId ${p.node.peerId.toString()}`);
@@ -472,9 +889,14 @@ async function cmdRecv(args) {
472
889
  console.log(`zestro bridge connected to ${bridge.url.replace(/\/[0-9a-f-]{36}\/ws$/, "/<token>/ws")}`);
473
890
  }
474
891
 
475
- p.onEnvelope(async (env) => {
892
+ // With delivery acks on, the library already acked every envelope; the
893
+ // bridge must not send a second ack for a request.
894
+ const autoAcks = p.receipts.delivered;
895
+ const handle = async (env) => {
476
896
  const line = { at: new Date().toISOString(), from: env.sender, kind: env.kind, payload: env.payload };
477
- if (env.payload?._group?.name) line.group = env.payload._group.name;
897
+ const tag = extractGroupTag(env.payload);
898
+ if (tag) { line.group = tag.name; line.groupId = tag.id; if (tag.msgId) line.msgId = tag.msgId; if (tag.op !== "message") line.op = tag.op; }
899
+ if (rk.named) line.id = env.id;
478
900
  console.log(JSON.stringify(line));
479
901
  if (bridge && env.kind === "request") {
480
902
  // Interaction-convention return leg: ack FIRST — pillar-ask allows 120s
@@ -489,7 +911,7 @@ async function cmdRecv(args) {
489
911
  const thread = env.payload?.thread ?? env.id;
490
912
  const senderKey = env.transportSignature.publicKey; // open() F1: sender derives from this key
491
913
  try {
492
- await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
914
+ if (!autoAcks) await p.send({ recipient: env.sender, recipientPublicKey: senderKey, kind: "ack", payload: { inReplyTo: env.id } });
493
915
  const result = await bridge.sendCommand({ command: JSON.stringify({ kind: env.kind, payload: env.payload, sender: env.sender }) });
494
916
  const hasText = result && typeof result === "object" &&
495
917
  ["text", "result", "output", "response", "message"].some(k => k in result);
@@ -532,9 +954,46 @@ async function cmdRecv(args) {
532
954
  // inject a Zestro agent turn per receipt. Print-only (line above).
533
955
  console.log(` → zestro: not bridged (kind=${env.kind} is a receipt)`);
534
956
  }
535
- });
957
+ };
536
958
 
537
- const shutdown = async () => { if (bridge) bridge.close(); await p.stop(); exit(0); };
959
+ let tail = null;
960
+ if (rk.named) {
961
+ // A named reader follows its own cursor over the LOCAL inbox, so it also
962
+ // sees rows another process of this home pulled first. Rows are handled
963
+ // strictly in order, at most once: the cursor moves past a row whose
964
+ // handler threw (handleInOrder), as an unnamed recv's handlers do.
965
+ const { decrypt } = await import("../src/net/envelope.mjs");
966
+ let busy = false;
967
+ const drainLocal = async () => {
968
+ if (busy) return;
969
+ busy = true;
970
+ try {
971
+ const cursor = p.mailbox.getReader(rk.key)?.cursor ?? 0;
972
+ await handleInOrder(p.mailbox.inboxAfter(cursor, 200), async (row) => {
973
+ const { env, payload } = viewOfRow(p, row, decrypt);
974
+ await handle({ ...env, payload });
975
+ }, {
976
+ advance: (row) => p.mailbox.setReaderCursor(rk.key, row.seq),
977
+ onError: (row, e) => console.error(`reader ${rk.key}: row ${row.seq} not handled, not retried: ${e.message}`),
978
+ });
979
+ } finally { busy = false; }
980
+ };
981
+ p.onEnvelope(() => drainLocal());
982
+ tail = setInterval(() => { drainLocal().catch((e) => console.error(`reader ${rk.key}: ${e.message}`)); }, 1000);
983
+ await drainLocal();
984
+ console.log(`reader "${rk.key}": cursor ${p.mailbox.getReader(rk.key)?.cursor ?? 0}`);
985
+ } else {
986
+ p.onEnvelope(handle);
987
+ }
988
+
989
+ const shutdown = async () => {
990
+ clearInterval(heartbeat);
991
+ if (tail) clearInterval(tail);
992
+ try { p.mailbox.releaseLease(rk.key, ctx); } catch (_e) {}
993
+ if (bridge) bridge.close();
994
+ await p.stop();
995
+ exit(0);
996
+ };
538
997
  process.on("SIGINT", shutdown);
539
998
  process.on("SIGTERM", shutdown);
540
999
  await new Promise(() => {});
@@ -545,47 +1004,99 @@ async function cmdInbox(args) {
545
1004
  // minted the uuaid. Pulls pending envelopes from the carrier (no long-poll),
546
1005
  // prints them decrypted, reports unread backlog, exits. --notify CMD runs a
547
1006
  // shell hook when there is mail (cron/launchd-friendly); --mark-read stamps
548
- // read_at on everything printed.
1007
+ // read_at on everything printed (and sends read receipts when they are on).
1008
+ //
1009
+ // Readers. Without --reader the output is exactly what it always was: "new"
1010
+ // is what THIS run pulled. With --reader NAME (or PILLAR_READER) this reader
1011
+ // has its own cursor over the local inbox and "new" is every row after it,
1012
+ // whoever pulled it, so several processes can share one home safely.
549
1013
  args["carrier-only"] = true; // inbox check needs the carrier only — no libp2p listener
550
- const p = loadPillar(args);
1014
+ const rk = readerFor(args);
1015
+ const ctx = processContext({ command: `pillar inbox${rk.named ? ` --reader ${rk.key}` : ""}` });
1016
+ const p = loadPillar(args, { reader: rk.key });
551
1017
  const fresh = [];
552
1018
  p.onEnvelope((env) => { fresh.push(env); });
553
1019
  await p.start();
554
1020
  // Address book BEFORE the drain: the trust "directory" policy needs known
555
1021
  // senders loaded, or every fresh envelope counts as a stranger.
556
- loadFleetAddressBook(fleetPath(args), p);
1022
+ loadContacts(args, p);
1023
+ warnAboutLiveReaders(p, rk, ctx, { showsUnread: !!args.unread });
1024
+ const prev = p.mailbox.getReader(rk.key);
1025
+ // A new named reader starts before the oldest unread row, computed BEFORE
1026
+ // this run's pull so that what it pulls is new to it too.
1027
+ const startCursor = prev ? prev.cursor : (rk.named ? initialCursor(p.mailbox) : p.mailbox.inboxTipSeq());
1028
+ p.mailbox.touchReader(rk.key, { ...ctx, cursor: startCursor });
557
1029
  const pulled = await p.drainCarrierInbox();
558
- // fromPublicKey: the sender's verified Ed25519 key (transportSignature —
559
- // open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
560
- // courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
561
- // via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
562
- for (const env of fresh) {
563
- console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload: env.payload }));
564
- }
565
- // Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
566
1030
  const { decrypt } = await import("../src/net/envelope.mjs");
567
- const freshIds = new Set(fresh.map(e => e.id));
568
- const backlog = (p.mailbox.inboxUnread?.(500) ?? []).filter(r => !freshIds.has(r.envelope_id));
569
- if (args.unread) {
570
- for (const row of backlog) {
571
- const env = JSON.parse(row.envelope_json);
572
- let payload = null;
573
- try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
574
- console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload }));
575
- }
576
- }
577
- const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
578
- console.log(`inbox: ${fresh.length} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
1031
+ const printed = []; // { id, sender, kind, payload, groupId } — for read receipts
1032
+
1033
+ let backlog = [];
1034
+ if (rk.named) {
1035
+ const rows = p.mailbox.inboxAfter(startCursor, 1000);
1036
+ for (const row of rows) {
1037
+ const { env, payload, groupId } = viewOfRow(p, row, decrypt);
1038
+ console.log(JSON.stringify(inboxLine(env, payload, { new: true })));
1039
+ printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload, groupId });
1040
+ }
1041
+ if (rows.length && !args.peek) p.mailbox.setReaderCursor(rk.key, rows.at(-1).seq);
1042
+ const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
1043
+ console.log(`inbox: ${rows.length} new for reader "${rk.key}" (${pulled} pulled from carriers by this run)${args.peek ? ", cursor not moved (--peek)" : ""}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
1044
+ } else {
1045
+ // fromPublicKey: the sender's verified Ed25519 key (transportSignature —
1046
+ // open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
1047
+ // courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
1048
+ // via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
1049
+ for (const env of fresh) {
1050
+ console.log(JSON.stringify(inboxLine(env, env.payload, { new: true })));
1051
+ printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload: env.payload, groupId: extractGroupTag(env.payload)?.id ?? null });
1052
+ }
1053
+ // Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
1054
+ const freshIds = new Set(fresh.map(e => e.id));
1055
+ backlog = (p.mailbox.inboxUnread?.(500) ?? []).filter(r => !freshIds.has(r.envelope_id));
1056
+ if (args.unread) {
1057
+ for (const row of backlog) {
1058
+ const { env, payload, groupId } = viewOfRow(p, row, decrypt);
1059
+ console.log(JSON.stringify(inboxLine(env, payload, { new: false })));
1060
+ printed.push({ id: env.id, sender: env.sender, kind: env.kind, payload, groupId });
1061
+ }
1062
+ }
1063
+ // Mail another process of this home pulled since this reader last looked:
1064
+ // it is not "new" here, and with --mark-read over there it is not even
1065
+ // unread. Say so, and who took it, instead of letting it vanish.
1066
+ // With --unread the unread ones were just printed, so only the ones some
1067
+ // other reader already marked read are missing.
1068
+ if (prev) {
1069
+ const taken = p.mailbox.inboxAfter(prev.cursor, 1000)
1070
+ .filter((r) => !freshIds.has(r.envelope_id) && r.pulled_by !== rk.key && (!args.unread || r.read_at));
1071
+ if (taken.length) {
1072
+ const by = new Map();
1073
+ for (const r of taken) by.set(r.pulled_by ?? null, (by.get(r.pulled_by ?? null) ?? 0) + 1);
1074
+ const readAlready = taken.filter((r) => r.read_at).length;
1075
+ for (const [who, count] of by) {
1076
+ console.error(`warning: ${count} envelope(s) reached this home since this reader last checked, pulled by ${describeReader(p.mailbox, who)}; they are not listed as new here.`);
1077
+ }
1078
+ const how = args.unread
1079
+ ? "Give each reader its own cursor with --reader NAME (or PILLAR_READER)."
1080
+ : "Print them with --unread, or give each reader its own cursor with --reader NAME (or PILLAR_READER).";
1081
+ console.error(` ${readAlready ? `${readAlready} of them are already marked read (only --reader shows those). ` : ""}${how}`);
1082
+ }
1083
+ }
1084
+ p.mailbox.setReaderCursor(rk.key, p.mailbox.inboxTipSeq());
1085
+ const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
1086
+ console.log(`inbox: ${fresh.length} new, ${backlog.length} older unread${args.unread ? "" : " (--unread to print)"}${qCount ? `, ${qCount} quarantined (pillar trust quarantine)` : ""}`);
1087
+ }
579
1088
  if (args["mark-read"]) {
580
- for (const env of fresh) p.mailbox.markInboxRead?.(env.id);
581
- if (args.unread) for (const row of backlog) p.mailbox.markInboxRead?.(row.envelope_id);
1089
+ for (const v of printed) p.mailbox.markInboxRead?.(v.id);
1090
+ const r = await p.sendReadReceipts(printed);
1091
+ if (r.sent || r.dropped) console.error(`read receipts: ${r.sent} sent${r.dropped ? `, ${r.dropped} not sent (per-run cap)` : ""}`);
582
1092
  }
583
- if (args.notify && typeof args.notify === "string" && (pulled > 0 || backlog.length > 0)) {
1093
+ const newCount = rk.named ? printed.length : pulled;
1094
+ if (args.notify && typeof args.notify === "string" && (newCount > 0 || backlog.length > 0)) {
584
1095
  const { spawnSync } = await import("node:child_process");
585
1096
  const r = spawnSync(args.notify, {
586
1097
  shell: true,
587
- input: JSON.stringify({ uuaid: p.uuaid, new: pulled, unread: backlog.length }) + "\n",
588
- env: { ...process.env, PILLAR_NEW: String(pulled), PILLAR_UNREAD: String(backlog.length), PILLAR_UUAID: p.uuaid },
1098
+ input: JSON.stringify({ uuaid: p.uuaid, new: newCount, unread: backlog.length }) + "\n",
1099
+ env: { ...process.env, PILLAR_NEW: String(newCount), PILLAR_UNREAD: String(backlog.length), PILLAR_UUAID: p.uuaid },
589
1100
  stdio: ["pipe", "inherit", "inherit"],
590
1101
  });
591
1102
  if (r.status !== 0) console.error(`notify hook exited ${r.status}`);
@@ -594,6 +1105,142 @@ async function cmdInbox(args) {
594
1105
  exit(0);
595
1106
  }
596
1107
 
1108
+ /**
1109
+ * pillar wait — sleep until there is mail, then exit 0 with the count.
1110
+ *
1111
+ * The wake signal for homes that cannot take a push (no open ports): one
1112
+ * outbound long-poll per carrier at zero CPU, woken by the carrier the moment
1113
+ * mail lands. It only PEEKS — nothing is stored, no cursor moves — so the
1114
+ * reader run it wakes still sees every message as new:
1115
+ *
1116
+ * while pillar wait --reader bot; do pillar inbox --reader bot --mark-read | my-agent; done
1117
+ *
1118
+ * Counts only mail that would reach the reader: quarantine-bound envelopes
1119
+ * (trust.json) never wake anyone. With --reader, rows another process already
1120
+ * pulled into this home after the reader's cursor count too.
1121
+ *
1122
+ * Prints {"uuaid","pending","carrier","local","kinds"}; exit 0 mail, 3 timeout.
1123
+ */
1124
+ async function cmdWait(args) {
1125
+ args["carrier-only"] = true;
1126
+ const rk = readerFor(args);
1127
+ const ctx = processContext({ command: `pillar wait${rk.named ? ` --reader ${rk.key}` : ""}` });
1128
+ const p = loadPillar(args, { reader: rk.key });
1129
+ await p.start();
1130
+ loadContacts(args, p);
1131
+ warnAboutLiveReaders(p, rk, ctx);
1132
+ const timeoutS = args.timeout !== undefined ? Number(args.timeout) : null;
1133
+ if (timeoutS !== null && (!Number.isFinite(timeoutS) || timeoutS < 0)) { console.error("--timeout takes a number of seconds"); exit(2); }
1134
+ const deadlineMs = timeoutS === null ? null : Date.now() + timeoutS * 1000;
1135
+ const cursor = () => {
1136
+ const r = p.mailbox.getReader(rk.key);
1137
+ return r ? r.cursor : initialCursor(p.mailbox);
1138
+ };
1139
+ const localRows = () => (rk.named ? p.mailbox.inboxAfter(cursor(), 1000) : []);
1140
+ const report = (carrierViews, local) => {
1141
+ const kinds = {};
1142
+ for (const k of [...carrierViews.map((v) => v.kind), ...local.map((r) => JSON.parse(r.envelope_json).kind)]) kinds[k] = (kinds[k] ?? 0) + 1;
1143
+ console.log(JSON.stringify({ uuaid: p.uuaid, ...(rk.named ? { reader: rk.key } : {}), pending: carrierViews.length + local.length, carrier: carrierViews.length, local: local.length, kinds }));
1144
+ };
1145
+ let local = localRows();
1146
+ if (local.length) { report([], local); await p.stop(); exit(0); }
1147
+ if (!p.carrierClient) { console.error("wait: this home has no carriers configured (pillar init --refresh --directory <url>)"); exit(2); }
1148
+ const views = await p.peekCarrierInbox({ deadlineMs, stopWhen: () => localRows().length > 0 });
1149
+ local = localRows();
1150
+ await p.stop();
1151
+ if (views.length || local.length) { report(views, local); exit(0); }
1152
+ console.log(JSON.stringify({ uuaid: p.uuaid, pending: 0, timedOut: true }));
1153
+ exit(3);
1154
+ }
1155
+
1156
+ /** pillar readers — every reader of this home, its cursor, and live leases. */
1157
+ async function cmdReaders(args) {
1158
+ const home = homePath(args);
1159
+ const { Mailbox } = await import("../src/mailbox/mailbox.mjs");
1160
+ if (!existsSync(join(home, "mailbox.db"))) { console.log(`no mailbox at ${home} yet`); return; }
1161
+ const mb = new Mailbox(join(home, "mailbox.db"));
1162
+ try {
1163
+ const tip = mb.inboxTipSeq();
1164
+ const live = otherLiveLeases(mb);
1165
+ const readers = mb.listReaders();
1166
+ if (!readers.length) console.log("no readers recorded yet");
1167
+ for (const r of readers) {
1168
+ const behind = mb.inboxAfter(r.cursor, 100000).length;
1169
+ const leases = live.filter((l) => l.reader === r.name);
1170
+ console.log(JSON.stringify({
1171
+ reader: r.name, named: !r.name.startsWith("ctx:"), cursor: r.cursor, unseen: behind,
1172
+ last: describeProcess(r), live: leases.map(describeProcess),
1173
+ }));
1174
+ }
1175
+ console.log(`inbox tip seq ${tip}; ${live.length} live lease(s)`);
1176
+ } finally { mb.close(); }
1177
+ }
1178
+
1179
+ /**
1180
+ * pillar status [envelope-id] [--limit N] [--wait S]
1181
+ *
1182
+ * The outbox as the sender can prove it: whether a carrier accepted each
1183
+ * envelope, and whether the RECIPIENT'S KEY signed an ack or a read receipt
1184
+ * for it. A carrier receipt is not delivery; a signed ack is the recipient's
1185
+ * mailbox saying it stored the envelope; neither says anyone acted on it.
1186
+ */
1187
+ async function cmdStatus(args) {
1188
+ const id = args._[1];
1189
+ args["carrier-only"] = true;
1190
+ const p = loadPillar(args);
1191
+ await p.start();
1192
+ try {
1193
+ if (id && args.wait !== undefined) {
1194
+ const row = p.mailbox.getOutbound(id);
1195
+ if (!row) { console.error(`no envelope ${id} in this home's outbox`); exit(2); }
1196
+ loadContacts(args, p);
1197
+ const secs = args.wait === true ? 60 : Number(args.wait);
1198
+ if (!Number.isFinite(secs) || secs <= 0) { console.error("--wait takes a number of seconds"); exit(2); }
1199
+ await waitForAck(p, id, row.recipient, Date.now() + secs * 1000);
1200
+ }
1201
+ const rows = id ? [p.mailbox.getOutbound(id)].filter(Boolean) : p.mailbox.outboxRecent(Number(args.limit ?? 20));
1202
+ if (id && !rows.length) { console.error(`no envelope ${id} in this home's outbox`); exit(2); }
1203
+ for (const r of rows) {
1204
+ console.log(JSON.stringify({
1205
+ id: r.envelope_id, to: r.recipient, kind: r.kind ?? null, at: r.created_at,
1206
+ ...(r.group_id ? { groupId: r.group_id, msgId: r.group_msg_id ?? null } : {}),
1207
+ acceptedByCarrier: r.status === "delivered" ? (r.delivered_at ?? true) : null,
1208
+ acknowledged: r.recipient_ack_at ?? null,
1209
+ read: r.recipient_read_at ?? null,
1210
+ status: describeOutboundStatus(r),
1211
+ }));
1212
+ }
1213
+ if (id && args.wait !== undefined && !rows[0].recipient_ack_at) exit(4);
1214
+ } finally {
1215
+ await p.stop();
1216
+ }
1217
+ }
1218
+
1219
+ /** pillar receipts [on|off|delivered on|off|read on|off] — config.json `receipts`. */
1220
+ async function cmdReceipts(args) {
1221
+ const home = homePath(args);
1222
+ const cfgPath = join(home, "config.json");
1223
+ if (!existsSync(join(home, "keychain.json"))) { console.error(`no pillar found at ${home}. run: pillar init`); exit(2); }
1224
+ const cfg = readConfig(home);
1225
+ const r = { delivered: !!cfg.receipts?.delivered, read: !!cfg.receipts?.read };
1226
+ const [a, b] = args._.slice(1);
1227
+ const onoff = (v) => (v === "on" ? true : v === "off" ? false : null);
1228
+ if (a === undefined) {
1229
+ // show only
1230
+ } else if (onoff(a) !== null && b === undefined) {
1231
+ r.delivered = r.read = onoff(a);
1232
+ } else if ((a === "delivered" || a === "read") && onoff(b) !== null) {
1233
+ r[a] = onoff(b);
1234
+ } else {
1235
+ console.error("usage: pillar receipts [on|off] | [delivered on|off] | [read on|off]");
1236
+ exit(2);
1237
+ }
1238
+ if (a !== undefined) { cfg.receipts = r; writeFileSync(cfgPath, JSON.stringify(cfg, null, 2)); }
1239
+ console.log(`delivery acks: ${r.delivered ? "on" : "off"} read receipts: ${r.read ? "on" : "off"} (${cfgPath})`);
1240
+ console.log(`sent only to contacts (fleet.json, trust allow, members of groups you accepted); read receipts go out on --mark-read.`);
1241
+ console.log(`read receipts are symmetric: with them off, incoming read receipts are ignored too. Delivery acks from others are always recorded.`);
1242
+ }
1243
+
597
1244
  async function cmdTrust(args) {
598
1245
  // pillar trust show policy + lists + quarantine count
599
1246
  // pillar trust set <open|directory|allowlist>
@@ -756,8 +1403,15 @@ async function main() {
756
1403
  case "tier": return cmdTier(args);
757
1404
  case "peers": return cmdPeers(args);
758
1405
  case "delegate": return cmdDelegate(args);
1406
+ case "wait": return cmdWait(args);
1407
+ case "readers": return cmdReaders(args);
1408
+ case "status": return cmdStatus(args);
1409
+ case "receipts": return cmdReceipts(args);
1410
+ case "group":
1411
+ if (wantsGroupV2(args)) return cmdGroupV2(args); // @rule P-cli-dispatch-v2
1412
+ return cmdGroup(args);
759
1413
  default:
760
- console.error(`usage: pillar <init|id|send|send-group|groups|recv|inbox|trust|tier|peers|delegate> [--home DIR]`);
1414
+ console.error(`usage: pillar <init|id|send|send-group|group|groups|recv|inbox|wait|readers|status|receipts|trust|tier|peers|delegate> [--home DIR]`);
761
1415
  exit(2);
762
1416
  }
763
1417
  }