@nicknisi/pi-relay 0.3.5 → 0.4.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/dist/mailbox.js CHANGED
@@ -9,13 +9,13 @@
9
9
  * failed delivery leaves the letter recoverable (recoverInboxClaims) or
10
10
  * requeueable.
11
11
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
12
- * - Consumption is the receipt: the sender learns "delivered" only when the
13
- * letter actually disappeared from the target's inbox and durable claims.
12
+ * - Durable acknowledgement is the receipt: disappearance without exact
13
+ * proof is uncertain, never evidence of delivery.
14
14
  * - Every deposit and every delivery appends one append-only audit line —
15
15
  * drain-as-receipt must not destroy evidence. The log never holds a full
16
16
  * body, only a short preview.
17
17
  */
18
- import { randomBytes } from 'node:crypto';
18
+ import { createHash, randomBytes } from 'node:crypto';
19
19
  import * as path from 'node:path';
20
20
  import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
21
21
  export const MAX_BODY_CHARS = 32 * 1024;
@@ -372,6 +372,36 @@ export function readClaimedLetter(root, addr, claimToken, fileToken) {
372
372
  relay.close();
373
373
  }
374
374
  }
375
+ function letterDigest(letter) {
376
+ return createHash('sha256')
377
+ .update(JSON.stringify([
378
+ letter.id,
379
+ letter.ts,
380
+ letter.kind,
381
+ letter.body,
382
+ letter.replyTo ?? null,
383
+ letter.from.addr,
384
+ letter.from.name,
385
+ letter.from.cwd,
386
+ ]))
387
+ .digest('hex');
388
+ }
389
+ function readAcknowledgement(raw, fileName) {
390
+ try {
391
+ const proof = raw === null ? null : JSON.parse(raw);
392
+ return proof &&
393
+ proof.fileName === fileName &&
394
+ typeof proof.digest === 'string' &&
395
+ /^[a-f0-9]{64}$/.test(proof.digest) &&
396
+ Number.isSafeInteger(proof.acknowledgedAt) &&
397
+ proof.acknowledgedAt >= 0
398
+ ? proof
399
+ : null;
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
375
405
  function finishClaimedLetter(root, addr, claimToken, fileToken, requeue) {
376
406
  assertPathSegment(addr, 'relay address');
377
407
  assertInboxClaimToken(claimToken);
@@ -400,6 +430,42 @@ function finishClaimedLetter(root, addr, claimToken, fileToken, requeue) {
400
430
  }
401
431
  }
402
432
  else {
433
+ // Persist proof before deleting the exact letter. Corrupt payloads are discarded without proof.
434
+ const raw = claim.readFile(fileToken);
435
+ if (raw !== null) {
436
+ let parsed;
437
+ try {
438
+ parsed = JSON.parse(raw);
439
+ }
440
+ catch {
441
+ /* corrupt letter */
442
+ }
443
+ if (parsed &&
444
+ typeof parsed.id === 'string' &&
445
+ MESSAGE_ID_PATTERN.test(parsed.id) &&
446
+ typeof parsed.body === 'string' &&
447
+ ['message', 'ask', 'reply', 'cancel'].includes(parsed.kind) &&
448
+ parsed.from &&
449
+ typeof parsed.from.addr === 'string' &&
450
+ typeof parsed.from.name === 'string' &&
451
+ typeof parsed.from.cwd === 'string' &&
452
+ Number.isSafeInteger(parsed.ts) &&
453
+ parsed.ts >= 0 &&
454
+ letterFileName(parsed) === fileToken) {
455
+ const acks = relay.openDirectory(`${addr}.acks`, true);
456
+ try {
457
+ const proof = {
458
+ fileName: fileToken,
459
+ digest: letterDigest(parsed),
460
+ acknowledgedAt: Date.now(),
461
+ };
462
+ acks.writeFileAtomic(fileToken, JSON.stringify(proof));
463
+ }
464
+ finally {
465
+ acks.close();
466
+ }
467
+ }
468
+ }
403
469
  finished = claim.unlinkFile(fileToken);
404
470
  if (finished)
405
471
  claim.sync();
@@ -428,6 +494,45 @@ export function ackClaimedLetter(root, addr, claimToken, fileToken) {
428
494
  export function requeueClaimedLetter(root, addr, claimToken, fileToken) {
429
495
  return finishClaimedLetter(root, addr, claimToken, fileToken, true);
430
496
  }
497
+ /** Retain exact acknowledgement proof for the mail retention window, including live sessions. */
498
+ export function sweepAcknowledgements(root, addr, now, retentionMs) {
499
+ assertPathSegment(addr, 'relay address');
500
+ const relay = openRelayRoot(root);
501
+ if (relay === null)
502
+ return 0;
503
+ try {
504
+ const acks = relay.openDirectory(`${addr}.acks`);
505
+ if (acks === null)
506
+ return 0;
507
+ let retained = 0;
508
+ try {
509
+ for (const entry of acks.readDirectory()) {
510
+ if (entry.isSymbolicLink())
511
+ throw new RelayFilesystemError(`Refusing symlinked relay acknowledgement: ${entry.name}`);
512
+ if (!entry.isFile()) {
513
+ retained++;
514
+ continue;
515
+ }
516
+ const parts = inboxFileTokenParts(entry.name);
517
+ const proof = readAcknowledgement(acks.readFile(entry.name), entry.name);
518
+ if (parts === null || proof === null || now - proof.acknowledgedAt < retentionMs) {
519
+ retained++;
520
+ continue;
521
+ }
522
+ acks.unlinkFile(entry.name);
523
+ }
524
+ acks.sync();
525
+ }
526
+ finally {
527
+ acks.close();
528
+ }
529
+ relay.removeEmptyDirectory(`${addr}.acks`);
530
+ return retained;
531
+ }
532
+ finally {
533
+ relay.close();
534
+ }
535
+ }
431
536
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
432
537
  function fileExistsInClaims(relay, addr, fileName) {
433
538
  const claims = relay.openDirectory(claimsDirectoryName(addr));
@@ -476,25 +581,34 @@ function receiptFileExists(relay, addr, fileName) {
476
581
  }
477
582
  return fileExistsInClaims(relay, addr, fileName);
478
583
  }
479
- /**
480
- * Consumption receipt: after depositing to a LIVE target, wait briefly for
481
- * the exact letter to vanish from both the live inbox and durable claims.
482
- * 'delivered' means the receiver acknowledged it, not merely claimed it.
483
- */
484
584
  export async function awaitReceipt(root, toAddr, letter, timeoutMs = 1500) {
485
585
  assertPathSegment(toAddr, 'relay address');
486
586
  const relay = openRelayRoot(root);
487
587
  if (relay === null)
488
- return 'delivered';
588
+ return 'uncertain';
489
589
  try {
490
590
  const fileName = letterFileName(letter);
591
+ const observe = () => {
592
+ const acks = relay.openDirectory(`${toAddr}.acks`);
593
+ if (acks !== null) {
594
+ try {
595
+ const proof = readAcknowledgement(acks.readFile(fileName), fileName);
596
+ if (proof?.digest === letterDigest(letter))
597
+ return 'delivered';
598
+ }
599
+ finally {
600
+ acks.close();
601
+ }
602
+ }
603
+ return receiptFileExists(relay, toAddr, fileName) ? 'queued' : 'uncertain';
604
+ };
491
605
  const deadline = Date.now() + timeoutMs;
492
606
  while (Date.now() < deadline) {
493
- if (!receiptFileExists(relay, toAddr, fileName))
607
+ if (observe() === 'delivered')
494
608
  return 'delivered';
495
609
  await sleep(100);
496
610
  }
497
- return receiptFileExists(relay, toAddr, fileName) ? 'queued' : 'delivered';
611
+ return observe();
498
612
  }
499
613
  finally {
500
614
  relay.close();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,183 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, expect, it, vi } from 'vitest';
5
+ import { ackClaimedLetter, awaitReceipt, claimInbox, deposit, sweepAcknowledgements } from './mailbox.js';
6
+ import { deriveAddr, sweep, SWEEP_MAIL_KEEP_MS, writeRecord } from './registry.js';
7
+ import { openRelayRoot, RelayDirectoryHandle } from './filesystem.js';
8
+ const roots = [];
9
+ afterEach(() => {
10
+ for (const root of roots.splice(0))
11
+ fs.rmSync(root, { recursive: true, force: true });
12
+ });
13
+ function fixture() {
14
+ const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'relay-receipt-')));
15
+ roots.push(root);
16
+ return root;
17
+ }
18
+ const addr = deriveAddr('/receiver', 'receiver');
19
+ function letter(id = 'proof', ts = Date.now()) {
20
+ return {
21
+ id,
22
+ ts,
23
+ kind: 'message',
24
+ body: 'fixture',
25
+ from: { addr: deriveAddr('/sender', 'sender'), name: 'sender', cwd: '/sender' },
26
+ };
27
+ }
28
+ it('does not infer delivery from absent roots, removed letters, or legacy roots without ACKs', async () => {
29
+ const root = fixture();
30
+ const l = letter();
31
+ expect(await awaitReceipt(path.join(root, 'absent'), addr, l, 0)).toBe('uncertain');
32
+ deposit(root, addr, l);
33
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
34
+ fs.unlinkSync(path.join(root, `${addr}.inbox`, `${l.ts}-${l.id}.json`));
35
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('uncertain');
36
+ });
37
+ it('persists exact proof before deletion and cleans empty claims on Linux and macOS', async () => {
38
+ const root = fixture();
39
+ const l = letter();
40
+ deposit(root, addr, l);
41
+ const claim = claimInbox(root, addr);
42
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
43
+ expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
44
+ expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken))).toBe(false);
45
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('delivered');
46
+ expect(await awaitReceipt(root, addr, { ...l, id: 'different' }, 0)).toBe('uncertain');
47
+ expect(await awaitReceipt(root, addr, { ...l, body: 'changed payload' }, 0)).toBe('uncertain');
48
+ expect(await awaitReceipt(root, addr, { ...l, from: { ...l.from, addr: deriveAddr('/other', 'other') } }, 0)).toBe('uncertain');
49
+ expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(false);
50
+ });
51
+ it('keeps mail claimed if the ACK path is unsafe, without writing through symlinks', async () => {
52
+ const root = fixture();
53
+ const outside = fixture();
54
+ const l = letter();
55
+ deposit(root, addr, l);
56
+ const claim = claimInbox(root, addr);
57
+ fs.symlinkSync(outside, path.join(root, `${addr}.acks`));
58
+ expect(() => ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toThrow(/symlink/i);
59
+ expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]))).toBe(true);
60
+ expect(fs.readdirSync(outside)).toEqual([]);
61
+ await expect(awaitReceipt(root, addr, l, 0)).rejects.toThrow(/symlink/i);
62
+ });
63
+ it('does not grant delivery proof to corrupt or mismatched payloads', async () => {
64
+ const root = fixture();
65
+ const l = letter();
66
+ deposit(root, addr, l);
67
+ const claim = claimInbox(root, addr);
68
+ fs.writeFileSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]), '{bad');
69
+ ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0]);
70
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('uncertain');
71
+ });
72
+ it('preserves claimed mail when persisting proof fails and supports a successful retry', async () => {
73
+ const root = fixture();
74
+ const l = letter();
75
+ deposit(root, addr, l);
76
+ const claim = claimInbox(root, addr);
77
+ const write = vi.spyOn(RelayDirectoryHandle.prototype, 'writeFileAtomic').mockImplementationOnce(() => {
78
+ throw new Error('fixture proof write failed');
79
+ });
80
+ try {
81
+ expect(() => ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toThrow(/proof write failed/);
82
+ expect(fs.existsSync(path.join(root, `${addr}.claims`, claim.claimToken, claim.fileTokens[0]))).toBe(true);
83
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('queued');
84
+ }
85
+ finally {
86
+ write.mockRestore();
87
+ }
88
+ expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
89
+ expect(await awaitReceipt(root, addr, l, 0)).toBe('delivered');
90
+ });
91
+ it('rejects symlinked proof files without reading their targets', async () => {
92
+ const root = fixture();
93
+ const outside = fixture();
94
+ const l = letter();
95
+ deposit(root, addr, l);
96
+ const claim = claimInbox(root, addr);
97
+ const token = claim.fileTokens[0];
98
+ ackClaimedLetter(root, addr, claim.claimToken, token);
99
+ const proofPath = path.join(root, `${addr}.acks`, token);
100
+ const outsideFile = path.join(outside, 'fixture-proof');
101
+ fs.writeFileSync(outsideFile, fs.readFileSync(proofPath));
102
+ fs.unlinkSync(proofPath);
103
+ fs.symlinkSync(outsideFile, proofPath);
104
+ await expect(awaitReceipt(root, addr, l, 0)).rejects.toThrow(/symlink/i);
105
+ });
106
+ it('expires proof for live sessions, retains malformed markers, and protects recent proof during sweep', () => {
107
+ const root = fixture();
108
+ const now = Date.now();
109
+ const old = letter('old', now - SWEEP_MAIL_KEEP_MS - 1);
110
+ const recent = letter('recent', now);
111
+ for (const l of [old, recent])
112
+ deposit(root, addr, l);
113
+ const claim = claimInbox(root, addr);
114
+ for (const token of claim.fileTokens)
115
+ ackClaimedLetter(root, addr, claim.claimToken, token);
116
+ const oldProofPath = path.join(root, `${addr}.acks`, `${old.ts}-${old.id}.json`);
117
+ const recentProofPath = path.join(root, `${addr}.acks`, `${recent.ts}-${recent.id}.json`);
118
+ // Retention starts at acknowledgement, not at the timestamp of long-queued mail.
119
+ const oldProof = JSON.parse(fs.readFileSync(oldProofPath, 'utf8'));
120
+ expect(oldProof.acknowledgedAt).toBeGreaterThanOrEqual(now);
121
+ fs.writeFileSync(oldProofPath, JSON.stringify({ ...oldProof, acknowledgedAt: now - SWEEP_MAIL_KEEP_MS - 1 }));
122
+ const recentProof = JSON.parse(fs.readFileSync(recentProofPath, 'utf8'));
123
+ fs.writeFileSync(recentProofPath, JSON.stringify({ ...recentProof, acknowledgedAt: now }));
124
+ writeRecord(root, {
125
+ addr,
126
+ sessionId: 'receiver',
127
+ name: 'receiver',
128
+ cwd: '/receiver',
129
+ pid: process.pid,
130
+ startedAt: now,
131
+ lastSeenAt: now,
132
+ status: 'idle',
133
+ });
134
+ sweep(root, now);
135
+ expect(fs.readdirSync(path.join(root, `${addr}.acks`))).toEqual([`${recent.ts}-${recent.id}.json`]);
136
+ writeRecord(root, {
137
+ addr,
138
+ sessionId: 'receiver',
139
+ name: 'receiver',
140
+ cwd: '/receiver',
141
+ pid: process.pid,
142
+ startedAt: now,
143
+ lastSeenAt: now - 60_000,
144
+ status: 'idle',
145
+ offline: true,
146
+ });
147
+ sweep(root, now, () => false);
148
+ expect(fs.existsSync(path.join(root, `${addr}.json`))).toBe(true);
149
+ fs.writeFileSync(path.join(root, `${addr}.acks`, 'malformed.json'), 'not proof');
150
+ expect(sweepAcknowledgements(root, addr, now + SWEEP_MAIL_KEEP_MS + 1, SWEEP_MAIL_KEEP_MS)).toBe(1);
151
+ });
152
+ it('never lets retained proof pin an expired offline record', () => {
153
+ const root = fixture();
154
+ const now = Date.now();
155
+ writeRecord(root, {
156
+ addr,
157
+ sessionId: 'receiver',
158
+ name: 'receiver',
159
+ cwd: '/receiver',
160
+ pid: process.pid,
161
+ startedAt: 0,
162
+ lastSeenAt: now - SWEEP_MAIL_KEEP_MS - 1,
163
+ status: 'idle',
164
+ offline: true,
165
+ });
166
+ fs.mkdirSync(path.join(root, `${addr}.acks`));
167
+ fs.writeFileSync(path.join(root, `${addr}.acks`, 'malformed.json'), 'not proof');
168
+ sweep(root, now, () => true);
169
+ expect(fs.existsSync(path.join(root, `${addr}.json`))).toBe(false);
170
+ expect(fs.existsSync(path.join(root, `${addr}.acks`))).toBe(false);
171
+ });
172
+ it('uses the platform directory-removal flag for descriptor-relative cleanup', () => {
173
+ const root = fixture();
174
+ const handle = openRelayRoot(root);
175
+ try {
176
+ handle.openDirectory('empty', true).close();
177
+ expect(handle.removeEmptyDirectory('empty')).toBe(true);
178
+ expect(fs.existsSync(path.join(root, 'empty'))).toBe(false);
179
+ }
180
+ finally {
181
+ handle.close();
182
+ }
183
+ });
package/dist/registry.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import * as crypto from 'node:crypto';
18
18
  import * as fs from 'node:fs';
19
19
  import * as path from 'node:path';
20
+ import { sweepAcknowledgements } from './mailbox.js';
20
21
  import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
21
22
  export const HEARTBEAT_STALE_MS = 45_000;
22
23
  /** A mailbox holding undelivered mail is kept this long after last contact. */
@@ -405,12 +406,17 @@ function removeClaims(directory, addr) {
405
406
  */
406
407
  export function sweep(root, now = Date.now(), sessionExists) {
407
408
  for (const record of listRecords(root)) {
409
+ const retainedAcknowledgements = sweepAcknowledgements(root, record.addr, now, SWEEP_MAIL_KEEP_MS);
408
410
  if (presenceOf(record, now) !== 'offline')
409
411
  continue;
410
412
  const hasMail = dirHasJson(root, `${record.addr}.inbox`) ||
411
413
  dirHasJson(root, `${record.addr}.asks`) ||
412
414
  claimsHaveJson(root, record.addr);
413
415
  const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
416
+ // Recent proof keeps a resumable record around, but never past expiry —
417
+ // otherwise one unreadable ack would pin a dead session (and its alias) forever.
418
+ if (retainedAcknowledgements > 0 && !expired)
419
+ continue;
414
420
  if (hasMail && !expired)
415
421
  continue;
416
422
  if (!expired && (sessionExists?.(record.sessionId) ?? true))
@@ -420,6 +426,7 @@ export function sweep(root, now = Date.now(), sessionExists) {
420
426
  try {
421
427
  relay.removeDirectory(`${record.addr}.inbox`);
422
428
  relay.removeDirectory(`${record.addr}.asks`);
429
+ relay.removeDirectory(`${record.addr}.acks`);
423
430
  removeClaims(relay, record.addr);
424
431
  try {
425
432
  relay.unlinkFile(`${record.addr}.json`);
@@ -7,7 +7,7 @@ import { createRequire, syncBuiltinESMExports } from 'node:module';
7
7
  import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
9
  import { afterEach, describe, expect, it } from 'vitest';
10
- import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
10
+ import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing } from './format.js';
11
11
  import { ackClaimedLetter, appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readClaimedLetter, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
12
12
  import { BACKLOG_CAP, OutboundPolicy, inboundAccepts } from './policy.js';
13
13
  import { claimAlias, clearAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
@@ -179,14 +179,14 @@ describe('mailbox', () => {
179
179
  deposit(root, addr, letter({ id: 'id-9', ts: 2000, body: 'good' }));
180
180
  expect(drain(root, addr).map((l) => l.body)).toEqual(['good']);
181
181
  });
182
- it('receipt: delivered when the letter vanishes, queued when it stays', async () => {
182
+ it('receipt: destructive drain leaves delivery uncertain, remaining letters stay queued', async () => {
183
183
  const root = tmpRoot();
184
184
  const addr = 'inbox0000003';
185
185
  const l1 = letter({ id: 'rcpt-1', ts: Date.now() });
186
186
  deposit(root, addr, l1);
187
187
  const drained = drain(root, addr); // receiver takes it
188
188
  expect(drained).toHaveLength(1);
189
- expect(await awaitReceipt(root, addr, l1, 400)).toBe('delivered');
189
+ expect(await awaitReceipt(root, addr, l1, 400)).toBe('uncertain');
190
190
  const l2 = letter({ id: 'rcpt-2', ts: Date.now() + 1 });
191
191
  deposit(root, addr, l2);
192
192
  expect(await awaitReceipt(root, addr, l2, 400)).toBe('queued');
@@ -610,10 +610,6 @@ describe('format', () => {
610
610
  expect(text).not.toContain('self00000000');
611
611
  expect(text).toContain('beta (peer00) — /tmp/alpha [idle]');
612
612
  });
613
- it('pins refusal strings', () => {
614
- expect(refusalUnknown('nobody', ['"alpha" (aaaa11)'])).toContain('No session matches');
615
- expect(refusalAmbiguous('al', ['"alpha" (aaaa11)', '"alpine" (bbbb22)'])).toContain('ambiguous');
616
- });
617
613
  });
618
614
  describe('relay root resolution', () => {
619
615
  // pi's home is commonly a dotfiles symlink (`~/.pi -> ~/Developer/dotfiles/home/.pi`).
@@ -0,0 +1,18 @@
1
+ import type { Letter } from './mailbox.js';
2
+ import type { SessionRecord } from './registry.js';
3
+ /** Exact session IDs share the target namespace with names and addresses. Never guess collisions. */
4
+ export declare function resolveSessionTarget(to: string, records: SessionRecord[], selfAddr: string): {
5
+ record?: SessionRecord;
6
+ error?: string;
7
+ };
8
+ interface ReplyTarget {
9
+ id: string;
10
+ addr: string;
11
+ ask: boolean;
12
+ }
13
+ /** Reconstruct ordinary reply targets from the active branch, not abandoned histories or process-local delivery state. */
14
+ export declare function resolveReplyTarget(reference: string, pending: Letter[], branch: readonly unknown[]): {
15
+ target?: ReplyTarget;
16
+ error?: string;
17
+ };
18
+ export {};
@@ -0,0 +1,58 @@
1
+ /** Exact session IDs share the target namespace with names and addresses. Never guess collisions. */
2
+ export function resolveSessionTarget(to, records, selfAddr) {
3
+ const peers = records.filter((r) => r.addr !== selfAddr);
4
+ const exact = peers.filter((r) => r.sessionId === to || r.addr === to || r.name.toLowerCase() === to.toLowerCase());
5
+ const matches = exact.length ? exact : peers.filter((r) => r.addr.startsWith(to));
6
+ const label = (r) => `${JSON.stringify(r.name)} (address ${r.addr}, sessionId ${JSON.stringify(r.sessionId)})`;
7
+ if (!matches.length)
8
+ return { error: `No session matches ${JSON.stringify(to)}. Use list or list-cwd to discover peers.` };
9
+ if (matches.length > 1)
10
+ return {
11
+ error: `${JSON.stringify(to)} is ambiguous: ${matches.map(label).join(', ')}. Use the intended full address.`,
12
+ };
13
+ return { record: matches[0] };
14
+ }
15
+ const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
16
+ const ADDRESS = /^[a-f0-9]{12}$/;
17
+ /** Reconstruct ordinary reply targets from the active branch, not abandoned histories or process-local delivery state. */
18
+ export function resolveReplyTarget(reference, pending, branch) {
19
+ if (!ID.test(reference))
20
+ return { error: 'replyTo must be a message ID or a nonempty unique prefix.' };
21
+ const candidates = pending.map((ask) => ({ id: ask.id, addr: ask.from.addr, ask: true }));
22
+ for (const entry of branch) {
23
+ if (typeof entry !== 'object' || entry === null)
24
+ continue;
25
+ const e = entry;
26
+ if (e.type !== 'custom_message' || e.customType !== 'relay:delivery')
27
+ continue;
28
+ const d = e.details;
29
+ if (typeof d !== 'object' || d === null)
30
+ continue;
31
+ const data = d;
32
+ if (data.kind !== 'message' && data.kind !== 'reply')
33
+ continue;
34
+ if (typeof data.id !== 'string' || !ID.test(data.id) || typeof data.from !== 'object' || data.from === null)
35
+ continue;
36
+ const addr = data.from.addr;
37
+ if (typeof addr !== 'string' || !ADDRESS.test(addr))
38
+ continue;
39
+ candidates.push({ id: data.id, addr, ask: false });
40
+ }
41
+ const matches = new Map();
42
+ const exact = candidates.filter((candidate) => candidate.id === reference);
43
+ for (const candidate of exact.length ? exact : candidates) {
44
+ if (!candidate.id.startsWith(reference))
45
+ continue;
46
+ const key = `${candidate.id}\0${candidate.addr}`;
47
+ matches.set(key, { ...candidate, ask: candidate.ask || matches.get(key)?.ask === true });
48
+ }
49
+ if (!matches.size)
50
+ return {
51
+ error: `No received message or pending ask matches ${JSON.stringify(reference)} in the active conversation. Use its full ID or list pending asks.`,
52
+ };
53
+ if (matches.size !== 1)
54
+ return {
55
+ error: `Reply ID ${JSON.stringify(reference)} is ambiguous. Use a longer prefix or full ID. Sender addresses: ${[...new Set([...matches.values()].map((m) => m.addr))].join(', ')}.`,
56
+ };
57
+ return { target: matches.values().next().value };
58
+ }
package/filesystem.ts CHANGED
@@ -25,7 +25,7 @@ const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
25
25
  const DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
26
26
  const CLOEXEC = process.platform === 'darwin' ? 0x01000000 : 0x00080000;
27
27
  const READ_DIRECTORY_FLAGS = fs.constants.O_RDONLY | NOFOLLOW | DIRECTORY | CLOEXEC;
28
- const AT_REMOVEDIR = 0x80;
28
+ const AT_REMOVEDIR = process.platform === 'darwin' ? 0x80 : 0x200;
29
29
 
30
30
  function loadLibc(): ReturnType<typeof koffi.load> {
31
31
  const candidates =
package/format.ts CHANGED
@@ -51,14 +51,6 @@ export function formatPendingAsk(letter: Letter, now: number = Date.now()): stri
51
51
  return `• ${letter.id.slice(0, 8)} from "${letter.from.name}" (${age(letter.ts, now)}): ${letter.body.replace(/\s+/g, ' ').slice(0, 80)}`;
52
52
  }
53
53
 
54
- export function refusalUnknown(to: string, reachable: string[]): string {
55
- return `No session matches '${to}'. Reachable: ${reachable.length > 0 ? reachable.join(', ') : '(none)'}.`;
56
- }
57
-
58
- export function refusalAmbiguous(to: string, candidates: string[]): string {
59
- return `'${to}' is ambiguous; matches: ${candidates.join(', ')}. Use a full name or address prefix.`;
60
- }
61
-
62
54
  /** Render recent audit entries for `/relay log`. */
63
55
  export function formatAudit(records: AuditRecord[], now: number = Date.now()): string {
64
56
  if (records.length === 0) return 'No audit entries yet.';