@nicknisi/pi-relay 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,7 @@ main moved; rebase before you push.
51
51
 
52
52
  - **A mailbox outlives the process.** The address is a hash of the working directory and pi's session id, so a session resumed with `pi -c` answers to the same address. Mail sent to a closed session waits on disk and is read when it resumes — the common case when you're opening and closing terminals all day.
53
53
  - **The queue is inspectable.** Diagnosing delivery is `ls`, not instrumenting a transport.
54
- - **Consumption is the receipt.** The receiver deletes the exact full-message-id letter as it reads it, so the sender learns _delivered_ vs _queued_. A durable core claim remains queued until it is acknowledged; moving it into a claim does not produce a false receipt.
54
+ - **Delivery is the receipt.** The receiver detaches its inbox into a durable claim and deletes each exact full-message-id letter only after the session has accepted it — a failed delivery is requeued for retry, and a crash mid-delivery leaves the letter recoverable on the next start (redeliveries are deduped by message id, seeded from the transcript). The sender therefore learns _delivered_ vs _queued_ honestly: a claimed-but-unacknowledged letter still reads _queued_, never a false receipt.
55
55
 
56
56
  ## Semantics
57
57
 
package/dist/index.js CHANGED
@@ -18,9 +18,15 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
18
18
  import { Box, getKeybindings, Text } from '@earendil-works/pi-tui';
19
19
  import { Type } from 'typebox';
20
20
  import { formatAudit, formatDelivery, formatListing, formatPendingAsk, refusalAmbiguous, refusalUnknown, shortAddr, } from './format.js';
21
- import { appendAudit, awaitReceipt, clearAsk, drain, deposit, outgoingAskIds, pendingAsks, previewBody, readAudit, readOutgoingAsk, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
21
+ import { ackClaimedLetter, appendAudit, awaitReceipt, claimInbox, clearAsk, deposit, outgoingAskIds, pendingAsks, previewBody, readAudit, readClaimedLetter, readOutgoingAsk, recoverInboxClaims, requeueClaimedLetter, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
22
22
  import { OutboundPolicy, inboundAccepts } from './policy.js';
23
- import { claimAlias, deriveAddr, ensureRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
23
+ import { claimAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
24
+ /** What to tell the caller when there is no session record to work with. */
25
+ function describeUninitialized(startupError, root) {
26
+ return startupError
27
+ ? `Relay failed to start: ${startupError} (relay root: ${root})`
28
+ : 'Relay is not initialized (no session_start yet).';
29
+ }
24
30
  function toolResult(text, details = {}) {
25
31
  return { content: [{ type: 'text', text }], details };
26
32
  }
@@ -93,16 +99,38 @@ function collectResumableSessionIds() {
93
99
  return ids;
94
100
  }
95
101
  export default function relay(pi) {
96
- const root = process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay');
102
+ // Resolve before any relay I/O: pi's home is commonly a dotfiles symlink,
103
+ // and the traversal hardening (rightly) refuses user-controlled ancestor
104
+ // symlinks on every operation. See resolveRelayRoot.
105
+ const root = resolveRelayRoot(process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay'));
97
106
  const policy = new OutboundPolicy();
98
107
  let self;
108
+ // Why registration failed, if it did. Without this a startup failure is
109
+ // indistinguishable from "session_start hasn't fired yet", and the whole
110
+ // extension goes quietly inert -- which is exactly how a symlinked pi home
111
+ // disabled relay machine-wide for a day and a half before anyone noticed.
112
+ let startupError;
113
+ const uninitializedText = () => describeUninitialized(startupError, root);
99
114
  let unwatch;
100
115
  let heartbeat;
101
116
  const askWaiters = new Map();
102
- // Backoff while the session can't accept mail: a re-deposited letter
117
+ // Backoff while the session can't accept mail: a requeued letter
103
118
  // re-fires the watcher, and without this a failing sendMessage would spin
104
- // drain→fail→re-deposit at watch speed.
119
+ // claim→fail→requeue at watch speed.
105
120
  let lastDeliveryFailureAt = 0;
121
+ // Letter ids this session has already accepted. Claims make redelivery
122
+ // possible (a crash between delivery and ack, or an ack failure after a
123
+ // successful send); without dedupe the model would see the body twice.
124
+ // Seeded from the transcript on session_start so recovery after a crash
125
+ // doesn't redeliver what the previous process already handed to the agent.
126
+ const deliveredIds = new Set();
127
+ const DELIVERED_IDS_CAP = 512;
128
+ const noteDelivered = (id) => {
129
+ deliveredIds.delete(id); // refresh recency
130
+ deliveredIds.add(id);
131
+ if (deliveredIds.size > DELIVERED_IDS_CAP)
132
+ deliveredIds.delete(deliveredIds.values().next().value);
133
+ };
106
134
  // Presence watch: addr → last observed presence. A poller surfaces
107
135
  // transitions as a notification message (no full turn wake).
108
136
  const watched = new Map();
@@ -122,7 +150,13 @@ export default function relay(pi) {
122
150
  function deliver(letter) {
123
151
  // Audited after the delivery attempts below: a 'deliver' record means the
124
152
  // session actually accepted the letter; refusal records 'deliver-failed'
125
- // (the caller re-deposits, so logging here would duplicate every retry).
153
+ // (the caller requeues, so logging here would duplicate every retry).
154
+ // Note pi.sendMessage is fire-and-forget: asynchronous failures are
155
+ // swallowed by the runtime (emitted as extension errors, never rethrown),
156
+ // so only synchronous refusals — a disposed/uninitialized runtime during
157
+ // reload or shutdown — reach these catches. That is exactly the case
158
+ // where the letter never enters the transcript: async rejections from the
159
+ // triggerTurn path happen after the message is already in context.
126
160
  const auditDelivery = (event) => appendAudit(root, {
127
161
  ts: Date.now(),
128
162
  event,
@@ -186,7 +220,7 @@ export default function relay(pi) {
186
220
  }
187
221
  catch {
188
222
  // session is shutting down or otherwise undeliverable — the caller
189
- // re-deposits the letter so a future drain retries
223
+ // requeues the letter so a future drain retries
190
224
  auditDelivery('deliver-failed');
191
225
  return false;
192
226
  }
@@ -196,40 +230,86 @@ export default function relay(pi) {
196
230
  function checkInbox() {
197
231
  if (!self)
198
232
  return;
233
+ const me = self.addr;
199
234
  if (Date.now() - lastDeliveryFailureAt < 5000)
200
235
  return;
201
- // Refuse mode: never drain. Letters stay on disk (receipts honestly read
236
+ // Refuse mode: never claim. Letters stay on disk (receipts honestly read
202
237
  // 'queued') instead of being silently consumed and dropped.
203
238
  if (!inboundAccepts())
204
239
  return;
205
- let letters;
240
+ // Claim, not drain: the inbox is detached atomically and each letter is
241
+ // only acked (deleted) once the session has accepted it. A claimed but
242
+ // unacked letter still reads 'queued' to the sender's receipt, survives
243
+ // a crash (recoverInboxClaims on the next session_start), and can be
244
+ // requeued for retry — unlink-as-read could silently lose it while the
245
+ // sender's receipt reported "delivered".
246
+ let claim;
206
247
  try {
207
- letters = drain(root, self.addr);
248
+ claim = claimInbox(root, me);
208
249
  }
209
250
  catch {
210
251
  return;
211
252
  }
212
- for (const letter of letters) {
253
+ if (!claim)
254
+ return;
255
+ const { claimToken, fileTokens } = claim;
256
+ const ack = (fileToken) => {
257
+ try {
258
+ ackClaimedLetter(root, me, claimToken, fileToken);
259
+ }
260
+ catch {
261
+ // ack failure leaves the letter claimed; recovery requeues it and
262
+ // deliveredIds suppresses the duplicate
263
+ }
264
+ };
265
+ for (const fileToken of fileTokens) {
266
+ let letter = null;
267
+ try {
268
+ letter = readClaimedLetter(root, me, claimToken, fileToken);
269
+ }
270
+ catch {
271
+ // transient read failure — keep it claimed rather than discarding
272
+ continue;
273
+ }
274
+ if (!letter || deliveredIds.has(letter.id)) {
275
+ // corrupt letters are discarded like drain does; already-accepted
276
+ // letters are redeliveries (crash between delivery and ack)
277
+ ack(fileToken);
278
+ continue;
279
+ }
213
280
  let accepted = false;
214
281
  try {
215
282
  accepted = deliver(letter);
216
283
  }
217
284
  catch {
218
- // a malformed delivery never blocks the rest of the drain
285
+ // a malformed delivery never blocks the rest of the claim
286
+ }
287
+ if (accepted) {
288
+ noteDelivered(letter.id);
289
+ ack(fileToken);
219
290
  }
220
- if (!accepted) {
291
+ else {
221
292
  lastDeliveryFailureAt = Date.now();
222
- // Not handed to the session — put the letter back so a future drain
223
- // retries it; otherwise drain's unlink-as-read would silently lose it
224
- // while the sender's receipt reported "delivered".
293
+ // Not handed to the session — atomically return it to the live inbox
294
+ // so the watch/poll retries it.
225
295
  try {
226
- deposit(root, self.addr, letter);
296
+ requeueClaimedLetter(root, me, claimToken, fileToken);
227
297
  }
228
298
  catch {
229
- // inbox dir unwritable; nothing more we can do
299
+ // stays claimed; the next session_start recovers it
230
300
  }
231
301
  }
232
302
  }
303
+ // claimInbox renamed the watched inbox dir away and created a fresh one,
304
+ // so the fs.watch is now on a detached directory. Re-arm on the live
305
+ // inbox (the 3s poll covers anything deposited in between).
306
+ try {
307
+ unwatch?.();
308
+ unwatch = watchInbox(root, me, checkInbox);
309
+ }
310
+ catch {
311
+ // watch failure is non-fatal; the old poll timer died with the old watch
312
+ }
233
313
  }
234
314
  function myFrom() {
235
315
  const s = self;
@@ -346,6 +426,7 @@ export default function relay(pi) {
346
426
  }
347
427
  pi.on('session_start', (_event, ctx) => {
348
428
  try {
429
+ startupError = undefined;
349
430
  ensureRoot(root);
350
431
  const sessionId = ctx.sessionManager.getSessionId();
351
432
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
@@ -363,6 +444,29 @@ export default function relay(pi) {
363
444
  writeRecord(root, self);
364
445
  const resumable = collectResumableSessionIds();
365
446
  sweep(root, Date.now(), (id) => resumable.has(id));
447
+ // Seed redelivery dedupe from the transcript: a letter delivered by a
448
+ // previous process but never acked (crash between delivery and ack)
449
+ // will be recovered below and must not be handed to the model twice.
450
+ for (const entry of ctx.sessionManager.getEntries()) {
451
+ if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE)
452
+ continue;
453
+ const id = entry.details?.id;
454
+ if (typeof id === 'string')
455
+ noteDelivered(id);
456
+ }
457
+ // Recover letters stranded in durable claims by a crash mid-delivery:
458
+ // return them to the live inbox so the normal watch/poll retries them.
459
+ // Runs before the watch is armed so requeues land in the watched inbox.
460
+ for (const stranded of recoverInboxClaims(root, self.addr)) {
461
+ for (const fileToken of stranded.fileTokens) {
462
+ try {
463
+ requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
464
+ }
465
+ catch {
466
+ // stays claimed; the next session_start retries recovery
467
+ }
468
+ }
469
+ }
366
470
  unwatch?.();
367
471
  unwatch = watchInbox(root, self.addr, checkInbox);
368
472
  heartbeat = setInterval(() => writeSelf({}), 15_000);
@@ -373,8 +477,11 @@ export default function relay(pi) {
373
477
  const initialDrain = setTimeout(checkInbox, 1200);
374
478
  initialDrain.unref();
375
479
  }
376
- catch {
377
- // registration failure never breaks the session
480
+ catch (error) {
481
+ // A failure here must never break the session -- but it must not be
482
+ // invisible either. Record it so every tool can say what actually
483
+ // happened instead of implying session_start simply hasn't run.
484
+ startupError = error instanceof Error ? error.message : String(error);
378
485
  }
379
486
  });
380
487
  pi.on('agent_start', () => writeSelf({ status: 'working' }));
@@ -427,7 +534,7 @@ export default function relay(pi) {
427
534
  }),
428
535
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
429
536
  if (!self)
430
- return toolResult('Relay is not initialized (no session_start yet).');
537
+ return toolResult(uninitializedText());
431
538
  switch (params.action) {
432
539
  case 'list':
433
540
  return toolResult(formatListing(listRecords(root), self.addr, (r) => presenceOf(r)));
@@ -660,7 +767,7 @@ export default function relay(pi) {
660
767
  if (sub === 'log') {
661
768
  const limit = Math.max(1, Math.min(500, Number(rest[0] ?? 50)));
662
769
  const entries = self ? readAudit(root, limit) : [];
663
- const text = self ? formatAudit(entries) : 'Relay is not initialized (no session_start yet).';
770
+ const text = self ? formatAudit(entries) : uninitializedText();
664
771
  if (!self || !ctx.hasUI) {
665
772
  pi.sendMessage({ customType: AUDIT_TYPE, content: text, display: true, details: { kind: 'relay-audit' } });
666
773
  return;
@@ -670,9 +777,7 @@ export default function relay(pi) {
670
777
  }
671
778
  if (!self || !ctx.hasUI) {
672
779
  // Plain-text fallback (print/rpc mode): the entry renderer never runs there.
673
- const text = self
674
- ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r))
675
- : 'Relay is not initialized (no session_start yet).';
780
+ const text = self ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r)) : uninitializedText();
676
781
  pi.sendMessage({
677
782
  customType: LIST_TYPE,
678
783
  content: text,
package/dist/mailbox.d.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  * Guarantees (each pinned by a test):
5
5
  * - A reader never sees half a letter: writers rename into place; readers
6
6
  * only look at `*.json`.
7
- * - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
8
- * the caller handles it.
7
+ * - A letter is deleted only once consumed: drain unlinks as it reads, and
8
+ * the durable claim flow deletes only on explicit ack — a crashed or
9
+ * failed delivery leaves the letter recoverable (recoverInboxClaims) or
10
+ * requeueable.
9
11
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
12
  * - Consumption is the receipt: the sender learns "delivered" only when the
11
- * letter actually disappeared from the target's inbox.
13
+ * letter actually disappeared from the target's inbox and durable claims.
12
14
  * - Every deposit and every delivery appends one append-only audit line —
13
15
  * drain-as-receipt must not destroy evidence. The log never holds a full
14
16
  * body, only a short preview.
@@ -32,8 +34,12 @@ export declare function deposit(root: string, toAddr: string, letter: Letter): v
32
34
  /**
33
35
  * Take every letter in the inbox, oldest first. Each letter is unlinked as
34
36
  * it is read — before parsing — so it can never be delivered twice. The
35
- * tradeoff: a drained letter that is never delivered would be lost, so the
36
- * caller must re-deposit any letter it fails to deliver (checkInbox does).
37
+ * tradeoff: a drained letter that is never delivered is lost, so callers
38
+ * that can fail delivery should prefer the durable claim flow (claimInbox →
39
+ * readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
40
+ * deletes a letter once it has actually been handed off. The pi entry point
41
+ * (index.ts checkInbox) uses claims; drain remains for core consumers that
42
+ * handle drained letters synchronously.
37
43
  */
38
44
  export declare function drain(root: string, addr: string): Letter[];
39
45
  export declare function unreadCount(root: string, addr: string): number;
package/dist/mailbox.js CHANGED
@@ -4,11 +4,13 @@
4
4
  * Guarantees (each pinned by a test):
5
5
  * - A reader never sees half a letter: writers rename into place; readers
6
6
  * only look at `*.json`.
7
- * - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
8
- * the caller handles it.
7
+ * - A letter is deleted only once consumed: drain unlinks as it reads, and
8
+ * the durable claim flow deletes only on explicit ack — a crashed or
9
+ * failed delivery leaves the letter recoverable (recoverInboxClaims) or
10
+ * requeueable.
9
11
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
12
  * - Consumption is the receipt: the sender learns "delivered" only when the
11
- * letter actually disappeared from the target's inbox.
13
+ * letter actually disappeared from the target's inbox and durable claims.
12
14
  * - Every deposit and every delivery appends one append-only audit line —
13
15
  * drain-as-receipt must not destroy evidence. The log never holds a full
14
16
  * body, only a short preview.
@@ -80,8 +82,12 @@ export function deposit(root, toAddr, letter) {
80
82
  /**
81
83
  * Take every letter in the inbox, oldest first. Each letter is unlinked as
82
84
  * it is read — before parsing — so it can never be delivered twice. The
83
- * tradeoff: a drained letter that is never delivered would be lost, so the
84
- * caller must re-deposit any letter it fails to deliver (checkInbox does).
85
+ * tradeoff: a drained letter that is never delivered is lost, so callers
86
+ * that can fail delivery should prefer the durable claim flow (claimInbox →
87
+ * readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
88
+ * deletes a letter once it has actually been handed off. The pi entry point
89
+ * (index.ts checkInbox) uses claims; drain remains for core consumers that
90
+ * handle drained letters synchronously.
85
91
  */
86
92
  export function drain(root, addr) {
87
93
  assertPathSegment(addr, 'relay address');
@@ -30,6 +30,25 @@ export declare const HEARTBEAT_STALE_MS = 45000;
30
30
  /** A mailbox holding undelivered mail is kept this long after last contact. */
31
31
  export declare const SWEEP_MAIL_KEEP_MS: number;
32
32
  export declare function deriveAddr(cwd: string, sessionId: string): string;
33
+ /**
34
+ * Pin the relay root to its real location, once, before any relay I/O.
35
+ *
36
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
37
+ * a user-writable symlink can be swapped between the check and the use. That
38
+ * is the right rule for every operation after startup — but it also rejects a
39
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
40
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
41
+ * relay root unopenable and the whole extension silently inert.
42
+ *
43
+ * Resolving once at startup keeps the property that matters. Every later
44
+ * operation traverses only real directories, so there is no user symlink left
45
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
46
+ * process. It is a pin, not a bypass.
47
+ *
48
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
49
+ * that does and re-apply the remainder.
50
+ */
51
+ export declare function resolveRelayRoot(root: string): string;
33
52
  export declare function ensureRoot(root: string): void;
34
53
  export declare function recordPath(root: string, addr: string): string;
35
54
  export declare function inboxDir(root: string, addr: string): string;
package/dist/registry.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * - Listing has NO side effects: reading the directory never deletes.
16
16
  */
17
17
  import * as crypto from 'node:crypto';
18
+ import * as fs from 'node:fs';
18
19
  import * as path from 'node:path';
19
20
  import { assertPathSegment, openRelayRoot, rethrowFilesystemError, RelayDirectoryHandle, RelayFilesystemError, } from './filesystem.js';
20
21
  export const HEARTBEAT_STALE_MS = 45_000;
@@ -23,6 +24,44 @@ export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
23
24
  export function deriveAddr(cwd, sessionId) {
24
25
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
25
26
  }
27
+ /**
28
+ * Pin the relay root to its real location, once, before any relay I/O.
29
+ *
30
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
31
+ * a user-writable symlink can be swapped between the check and the use. That
32
+ * is the right rule for every operation after startup — but it also rejects a
33
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
34
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
35
+ * relay root unopenable and the whole extension silently inert.
36
+ *
37
+ * Resolving once at startup keeps the property that matters. Every later
38
+ * operation traverses only real directories, so there is no user symlink left
39
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
40
+ * process. It is a pin, not a bypass.
41
+ *
42
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
43
+ * that does and re-apply the remainder.
44
+ */
45
+ export function resolveRelayRoot(root) {
46
+ const absolute = path.resolve(root);
47
+ const tail = [];
48
+ let current = absolute;
49
+ for (;;) {
50
+ try {
51
+ return path.join(fs.realpathSync(current), ...tail);
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT'))
55
+ throw error;
56
+ const parent = path.dirname(current);
57
+ // Filesystem root does not exist: nothing sane left to resolve against.
58
+ if (parent === current)
59
+ return absolute;
60
+ tail.unshift(path.basename(current));
61
+ current = parent;
62
+ }
63
+ }
64
+ }
26
65
  export function ensureRoot(root) {
27
66
  const directory = openRelayRoot(root, true);
28
67
  directory.close();
@@ -8,9 +8,9 @@ import * as os from 'node:os';
8
8
  import * as path from 'node:path';
9
9
  import { afterEach, describe, expect, it } from 'vitest';
10
10
  import { BOUNDARY_PREAMBLE, formatAudit, formatDelivery, formatListing, refusalAmbiguous, refusalUnknown, } from './format.js';
11
- import { appendAudit, auditLogPath, awaitReceipt, claimInbox, clearAsk, deposit, drain, pendingAsks, previewBody, readAudit, readIncomingAsk, readOutgoingAsk, recoverInboxClaims, resolveAskByRef, trackIncomingAsk, trackOutgoingAsk, unreadCount, watchInbox, } from './mailbox.js';
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
- import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
13
+ import { claimAlias, clearAlias, deriveAddr, ensureRoot, resolveRelayRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
14
14
  const require = createRequire(import.meta.url);
15
15
  const dirs = [];
16
16
  function tmpRoot() {
@@ -191,6 +191,34 @@ describe('mailbox', () => {
191
191
  deposit(root, addr, l2);
192
192
  expect(await awaitReceipt(root, addr, l2, 400)).toBe('queued');
193
193
  });
194
+ it("a letter reads 'queued' to the sender until actually delivered (claim → fail → requeue → ack)", async () => {
195
+ // The pi entry point's delivery flow: a claimed letter stays 'queued' to
196
+ // the sender, a failed delivery returns it to the inbox for retry, and
197
+ // the receipt flips to 'delivered' only after acceptance (ack). A crash
198
+ // mid-claim leaves the letter recoverable instead of silently lost.
199
+ const root = tmpRoot();
200
+ const addr = 'inbox0000004';
201
+ const l1 = letter({ id: 'rcpt-3', ts: Date.now() });
202
+ deposit(root, addr, l1);
203
+ const claim = claimInbox(root, addr);
204
+ expect(claim.fileTokens).toHaveLength(1);
205
+ // Claimed but not yet delivered: the sender must NOT see 'delivered'.
206
+ expect(await awaitReceipt(root, addr, l1, 400)).toBe('queued');
207
+ expect(readClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])?.id).toBe(l1.id);
208
+ // Delivery failure: requeue returns the letter to the live inbox, and a
209
+ // later claim picks it up again.
210
+ expect(requeueClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
211
+ expect(unreadCount(root, addr)).toBe(1);
212
+ expect(recoverInboxClaims(root, addr)).toEqual([]);
213
+ const retry = claimInbox(root, addr);
214
+ expect(retry.fileTokens).toEqual(claim.fileTokens);
215
+ // Crash after delivery but before ack: the letter is recoverable.
216
+ expect(recoverInboxClaims(root, addr)).toEqual([retry]);
217
+ // Acceptance: only the ack deletes the letter and flips the receipt.
218
+ expect(ackClaimedLetter(root, addr, retry.claimToken, retry.fileTokens[0])).toBe(true);
219
+ expect(recoverInboxClaims(root, addr)).toEqual([]);
220
+ expect(await awaitReceipt(root, addr, l1, 400)).toBe('delivered');
221
+ });
194
222
  it('watch fires on deposit (poll fallback covers missed events)', async () => {
195
223
  const root = tmpRoot();
196
224
  const addr = 'inbox0000004';
@@ -587,3 +615,35 @@ describe('format', () => {
587
615
  expect(refusalAmbiguous('al', ['"alpha" (aaaa11)', '"alpine" (bbbb22)'])).toContain('ambiguous');
588
616
  });
589
617
  });
618
+ describe('relay root resolution', () => {
619
+ // pi's home is commonly a dotfiles symlink (`~/.pi -> ~/Developer/dotfiles/home/.pi`).
620
+ // The traversal hardening refuses user-controlled ancestor symlinks, so an
621
+ // unresolved root made every relay operation throw -- and because
622
+ // session_start swallowed it, relay went silently inert machine-wide.
623
+ // Note the rest of this suite calls fs.realpathSync.native() on its temp
624
+ // dirs, which is exactly why that path was never covered.
625
+ it('resolves a user-symlinked ancestor so the hardened root can be opened', () => {
626
+ const real = tmpRoot();
627
+ const link = path.join(tmpRoot(), 'link');
628
+ fs.symlinkSync(real, link, 'dir');
629
+ const root = path.join(link, 'relay');
630
+ // The hardening rejects the symlinked path itself -- deliberately.
631
+ expect(() => ensureRoot(root)).toThrow(/symlink/i);
632
+ // Resolving first yields the same directory by its real path, which opens.
633
+ const resolved = resolveRelayRoot(root);
634
+ expect(resolved).toBe(path.join(real, 'relay'));
635
+ expect(() => ensureRoot(resolved)).not.toThrow();
636
+ });
637
+ it('resolves a root whose directories do not exist yet', () => {
638
+ const real = tmpRoot();
639
+ const link = path.join(tmpRoot(), 'link');
640
+ fs.symlinkSync(real, link, 'dir');
641
+ // First run: nothing under the root exists, so the deepest existing
642
+ // ancestor is resolved and the missing tail re-applied.
643
+ expect(resolveRelayRoot(path.join(link, 'a', 'b', 'relay'))).toBe(path.join(real, 'a', 'b', 'relay'));
644
+ });
645
+ it('leaves an already-real path unchanged', () => {
646
+ const real = tmpRoot();
647
+ expect(resolveRelayRoot(path.join(real, 'relay'))).toBe(path.join(real, 'relay'));
648
+ });
649
+ });
package/index.ts CHANGED
@@ -29,16 +29,20 @@ import {
29
29
  shortAddr,
30
30
  } from './format.js';
31
31
  import {
32
+ ackClaimedLetter,
32
33
  appendAudit,
33
34
  awaitReceipt,
35
+ claimInbox,
34
36
  clearAsk,
35
- drain,
36
37
  deposit,
37
38
  outgoingAskIds,
38
39
  pendingAsks,
39
40
  previewBody,
40
41
  readAudit,
42
+ readClaimedLetter,
41
43
  readOutgoingAsk,
44
+ recoverInboxClaims,
45
+ requeueClaimedLetter,
42
46
  resolveAskByRef,
43
47
  trackIncomingAsk,
44
48
  trackOutgoingAsk,
@@ -51,6 +55,7 @@ import {
51
55
  claimAlias,
52
56
  deriveAddr,
53
57
  ensureRoot,
58
+ resolveRelayRoot,
54
59
  isValidAliasName,
55
60
  listAliases,
56
61
  listRecords,
@@ -65,6 +70,13 @@ import {
65
70
 
66
71
  type AskOutcome = { replied: true; body: string; from: string } | { replied: false; reason: string };
67
72
 
73
+ /** What to tell the caller when there is no session record to work with. */
74
+ function describeUninitialized(startupError: string | undefined, root: string): string {
75
+ return startupError
76
+ ? `Relay failed to start: ${startupError} (relay root: ${root})`
77
+ : 'Relay is not initialized (no session_start yet).';
78
+ }
79
+
68
80
  function toolResult(text: string, details: Record<string, unknown> = {}) {
69
81
  return { content: [{ type: 'text' as const, text }], details };
70
82
  }
@@ -162,17 +174,38 @@ function collectResumableSessionIds(): Set<string> {
162
174
  }
163
175
 
164
176
  export default function relay(pi: ExtensionAPI) {
165
- const root = process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay');
177
+ // Resolve before any relay I/O: pi's home is commonly a dotfiles symlink,
178
+ // and the traversal hardening (rightly) refuses user-controlled ancestor
179
+ // symlinks on every operation. See resolveRelayRoot.
180
+ const root = resolveRelayRoot(process.env.PI_RELAY_DIR ?? path.join(getAgentDir(), 'relay'));
166
181
  const policy = new OutboundPolicy();
167
182
 
168
183
  let self: SessionRecord | undefined;
184
+ // Why registration failed, if it did. Without this a startup failure is
185
+ // indistinguishable from "session_start hasn't fired yet", and the whole
186
+ // extension goes quietly inert -- which is exactly how a symlinked pi home
187
+ // disabled relay machine-wide for a day and a half before anyone noticed.
188
+ let startupError: string | undefined;
189
+ const uninitializedText = (): string => describeUninitialized(startupError, root);
169
190
  let unwatch: (() => void) | undefined;
170
191
  let heartbeat: ReturnType<typeof setInterval> | undefined;
171
192
  const askWaiters = new Map<string, (outcome: AskOutcome) => void>();
172
- // Backoff while the session can't accept mail: a re-deposited letter
193
+ // Backoff while the session can't accept mail: a requeued letter
173
194
  // re-fires the watcher, and without this a failing sendMessage would spin
174
- // drain→fail→re-deposit at watch speed.
195
+ // claim→fail→requeue at watch speed.
175
196
  let lastDeliveryFailureAt = 0;
197
+ // Letter ids this session has already accepted. Claims make redelivery
198
+ // possible (a crash between delivery and ack, or an ack failure after a
199
+ // successful send); without dedupe the model would see the body twice.
200
+ // Seeded from the transcript on session_start so recovery after a crash
201
+ // doesn't redeliver what the previous process already handed to the agent.
202
+ const deliveredIds = new Set<string>();
203
+ const DELIVERED_IDS_CAP = 512;
204
+ const noteDelivered = (id: string): void => {
205
+ deliveredIds.delete(id); // refresh recency
206
+ deliveredIds.add(id);
207
+ if (deliveredIds.size > DELIVERED_IDS_CAP) deliveredIds.delete(deliveredIds.values().next().value!);
208
+ };
176
209
  // Presence watch: addr → last observed presence. A poller surfaces
177
210
  // transitions as a notification message (no full turn wake).
178
211
  const watched = new Map<string, Presence>();
@@ -192,7 +225,13 @@ export default function relay(pi: ExtensionAPI) {
192
225
  function deliver(letter: Letter): boolean {
193
226
  // Audited after the delivery attempts below: a 'deliver' record means the
194
227
  // session actually accepted the letter; refusal records 'deliver-failed'
195
- // (the caller re-deposits, so logging here would duplicate every retry).
228
+ // (the caller requeues, so logging here would duplicate every retry).
229
+ // Note pi.sendMessage is fire-and-forget: asynchronous failures are
230
+ // swallowed by the runtime (emitted as extension errors, never rethrown),
231
+ // so only synchronous refusals — a disposed/uninitialized runtime during
232
+ // reload or shutdown — reach these catches. That is exactly the case
233
+ // where the letter never enters the transcript: async rejections from the
234
+ // triggerTurn path happen after the message is already in context.
196
235
  const auditDelivery = (event: 'deliver' | 'deliver-failed') =>
197
236
  appendAudit(root, {
198
237
  ts: Date.now(),
@@ -255,7 +294,7 @@ export default function relay(pi: ExtensionAPI) {
255
294
  return true;
256
295
  } catch {
257
296
  // session is shutting down or otherwise undeliverable — the caller
258
- // re-deposits the letter so a future drain retries
297
+ // requeues the letter so a future drain retries
259
298
  auditDelivery('deliver-failed');
260
299
  return false;
261
300
  }
@@ -265,35 +304,76 @@ export default function relay(pi: ExtensionAPI) {
265
304
 
266
305
  function checkInbox(): void {
267
306
  if (!self) return;
307
+ const me = self.addr;
268
308
  if (Date.now() - lastDeliveryFailureAt < 5000) return;
269
- // Refuse mode: never drain. Letters stay on disk (receipts honestly read
309
+ // Refuse mode: never claim. Letters stay on disk (receipts honestly read
270
310
  // 'queued') instead of being silently consumed and dropped.
271
311
  if (!inboundAccepts()) return;
272
- let letters: Letter[];
312
+ // Claim, not drain: the inbox is detached atomically and each letter is
313
+ // only acked (deleted) once the session has accepted it. A claimed but
314
+ // unacked letter still reads 'queued' to the sender's receipt, survives
315
+ // a crash (recoverInboxClaims on the next session_start), and can be
316
+ // requeued for retry — unlink-as-read could silently lose it while the
317
+ // sender's receipt reported "delivered".
318
+ let claim: ReturnType<typeof claimInbox>;
273
319
  try {
274
- letters = drain(root, self.addr);
320
+ claim = claimInbox(root, me);
275
321
  } catch {
276
322
  return;
277
323
  }
278
- for (const letter of letters) {
324
+ if (!claim) return;
325
+ const { claimToken, fileTokens } = claim;
326
+ const ack = (fileToken: string) => {
327
+ try {
328
+ ackClaimedLetter(root, me, claimToken, fileToken);
329
+ } catch {
330
+ // ack failure leaves the letter claimed; recovery requeues it and
331
+ // deliveredIds suppresses the duplicate
332
+ }
333
+ };
334
+ for (const fileToken of fileTokens) {
335
+ let letter: Letter | null = null;
336
+ try {
337
+ letter = readClaimedLetter(root, me, claimToken, fileToken);
338
+ } catch {
339
+ // transient read failure — keep it claimed rather than discarding
340
+ continue;
341
+ }
342
+ if (!letter || deliveredIds.has(letter.id)) {
343
+ // corrupt letters are discarded like drain does; already-accepted
344
+ // letters are redeliveries (crash between delivery and ack)
345
+ ack(fileToken);
346
+ continue;
347
+ }
279
348
  let accepted = false;
280
349
  try {
281
350
  accepted = deliver(letter);
282
351
  } catch {
283
- // a malformed delivery never blocks the rest of the drain
352
+ // a malformed delivery never blocks the rest of the claim
284
353
  }
285
- if (!accepted) {
354
+ if (accepted) {
355
+ noteDelivered(letter.id);
356
+ ack(fileToken);
357
+ } else {
286
358
  lastDeliveryFailureAt = Date.now();
287
- // Not handed to the session — put the letter back so a future drain
288
- // retries it; otherwise drain's unlink-as-read would silently lose it
289
- // while the sender's receipt reported "delivered".
359
+ // Not handed to the session — atomically return it to the live inbox
360
+ // so the watch/poll retries it.
290
361
  try {
291
- deposit(root, self.addr, letter);
362
+ requeueClaimedLetter(root, me, claimToken, fileToken);
292
363
  } catch {
293
- // inbox dir unwritable; nothing more we can do
364
+ // stays claimed; the next session_start recovers it
294
365
  }
295
366
  }
296
367
  }
368
+ // claimInbox renamed the watched inbox dir away and created a fresh one,
369
+ // so the fs.watch is now on a detached directory. Re-arm on the live
370
+ // inbox (the 3s poll covers anything deposited in between).
371
+ try {
372
+ unwatch?.();
373
+ unwatch = watchInbox(root, me, checkInbox);
374
+ } catch {
375
+ // watch failure is non-fatal; the old poll timer died with the old watch
376
+ }
297
377
  }
298
378
 
299
379
  function myFrom(): { addr: string; name: string; cwd: string } {
@@ -415,6 +495,7 @@ export default function relay(pi: ExtensionAPI) {
415
495
 
416
496
  pi.on('session_start', (_event, ctx: ExtensionContext) => {
417
497
  try {
498
+ startupError = undefined;
418
499
  ensureRoot(root);
419
500
  const sessionId = ctx.sessionManager.getSessionId();
420
501
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
@@ -432,6 +513,26 @@ export default function relay(pi: ExtensionAPI) {
432
513
  writeRecord(root, self);
433
514
  const resumable = collectResumableSessionIds();
434
515
  sweep(root, Date.now(), (id) => resumable.has(id));
516
+ // Seed redelivery dedupe from the transcript: a letter delivered by a
517
+ // previous process but never acked (crash between delivery and ack)
518
+ // will be recovered below and must not be handed to the model twice.
519
+ for (const entry of ctx.sessionManager.getEntries()) {
520
+ if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE) continue;
521
+ const id = (entry.details as { id?: unknown } | undefined)?.id;
522
+ if (typeof id === 'string') noteDelivered(id);
523
+ }
524
+ // Recover letters stranded in durable claims by a crash mid-delivery:
525
+ // return them to the live inbox so the normal watch/poll retries them.
526
+ // Runs before the watch is armed so requeues land in the watched inbox.
527
+ for (const stranded of recoverInboxClaims(root, self.addr)) {
528
+ for (const fileToken of stranded.fileTokens) {
529
+ try {
530
+ requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
531
+ } catch {
532
+ // stays claimed; the next session_start retries recovery
533
+ }
534
+ }
535
+ }
435
536
  unwatch?.();
436
537
  unwatch = watchInbox(root, self.addr, checkInbox);
437
538
  heartbeat = setInterval(() => writeSelf({}), 15_000);
@@ -441,8 +542,11 @@ export default function relay(pi: ExtensionAPI) {
441
542
  // processing"); by the time this fires, steer/triggerTurn work.
442
543
  const initialDrain = setTimeout(checkInbox, 1200);
443
544
  initialDrain.unref();
444
- } catch {
445
- // registration failure never breaks the session
545
+ } catch (error) {
546
+ // A failure here must never break the session -- but it must not be
547
+ // invisible either. Record it so every tool can say what actually
548
+ // happened instead of implying session_start simply hasn't run.
549
+ startupError = error instanceof Error ? error.message : String(error);
446
550
  }
447
551
  });
448
552
 
@@ -505,7 +609,7 @@ export default function relay(pi: ExtensionAPI) {
505
609
  }),
506
610
 
507
611
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
508
- if (!self) return toolResult('Relay is not initialized (no session_start yet).');
612
+ if (!self) return toolResult(uninitializedText());
509
613
 
510
614
  switch (params.action) {
511
615
  case 'list':
@@ -740,7 +844,7 @@ export default function relay(pi: ExtensionAPI) {
740
844
  if (sub === 'log') {
741
845
  const limit = Math.max(1, Math.min(500, Number(rest[0] ?? 50)));
742
846
  const entries = self ? readAudit(root, limit) : [];
743
- const text = self ? formatAudit(entries) : 'Relay is not initialized (no session_start yet).';
847
+ const text = self ? formatAudit(entries) : uninitializedText();
744
848
  if (!self || !ctx.hasUI) {
745
849
  pi.sendMessage({ customType: AUDIT_TYPE, content: text, display: true, details: { kind: 'relay-audit' } });
746
850
  return;
@@ -750,9 +854,7 @@ export default function relay(pi: ExtensionAPI) {
750
854
  }
751
855
  if (!self || !ctx.hasUI) {
752
856
  // Plain-text fallback (print/rpc mode): the entry renderer never runs there.
753
- const text = self
754
- ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r))
755
- : 'Relay is not initialized (no session_start yet).';
857
+ const text = self ? formatListing(listRecords(root), self.addr, (r) => presenceOf(r)) : uninitializedText();
756
858
  pi.sendMessage({
757
859
  customType: LIST_TYPE,
758
860
  content: text,
package/mailbox.ts CHANGED
@@ -4,11 +4,13 @@
4
4
  * Guarantees (each pinned by a test):
5
5
  * - A reader never sees half a letter: writers rename into place; readers
6
6
  * only look at `*.json`.
7
- * - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
8
- * the caller handles it.
7
+ * - A letter is deleted only once consumed: drain unlinks as it reads, and
8
+ * the durable claim flow deletes only on explicit ack — a crashed or
9
+ * failed delivery leaves the letter recoverable (recoverInboxClaims) or
10
+ * requeueable.
9
11
  * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
12
  * - Consumption is the receipt: the sender learns "delivered" only when the
11
- * letter actually disappeared from the target's inbox.
13
+ * letter actually disappeared from the target's inbox and durable claims.
12
14
  * - Every deposit and every delivery appends one append-only audit line —
13
15
  * drain-as-receipt must not destroy evidence. The log never holds a full
14
16
  * body, only a short preview.
@@ -103,8 +105,12 @@ export function deposit(root: string, toAddr: string, letter: Letter): void {
103
105
  /**
104
106
  * Take every letter in the inbox, oldest first. Each letter is unlinked as
105
107
  * it is read — before parsing — so it can never be delivered twice. The
106
- * tradeoff: a drained letter that is never delivered would be lost, so the
107
- * caller must re-deposit any letter it fails to deliver (checkInbox does).
108
+ * tradeoff: a drained letter that is never delivered is lost, so callers
109
+ * that can fail delivery should prefer the durable claim flow (claimInbox →
110
+ * readClaimedLetter → ackClaimedLetter/requeueClaimedLetter), which only
111
+ * deletes a letter once it has actually been handed off. The pi entry point
112
+ * (index.ts checkInbox) uses claims; drain remains for core consumers that
113
+ * handle drained letters synchronously.
108
114
  */
109
115
  export function drain(root: string, addr: string): Letter[] {
110
116
  assertPathSegment(addr, 'relay address');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-relay",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Brokerless session-to-session messaging for pi — a file mailbox that outlives the process, with ask/reply coordination. Formerly @nicknisi/pi-intercom.",
5
5
  "keywords": [
6
6
  "pi",
package/registry.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import * as crypto from 'node:crypto';
19
+ import * as fs from 'node:fs';
19
20
  import * as path from 'node:path';
20
21
  import {
21
22
  assertPathSegment,
@@ -47,6 +48,42 @@ export function deriveAddr(cwd: string, sessionId: string): string {
47
48
  return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
48
49
  }
49
50
 
51
+ /**
52
+ * Pin the relay root to its real location, once, before any relay I/O.
53
+ *
54
+ * The traversal hardening refuses *user-controlled* ancestor symlinks, because
55
+ * a user-writable symlink can be swapped between the check and the use. That
56
+ * is the right rule for every operation after startup — but it also rejects a
57
+ * legitimate, stable arrangement: a dotfiles setup where `~/.pi` is a symlink
58
+ * into a managed repo. Since pi keeps everything under `~/.pi`, that made the
59
+ * relay root unopenable and the whole extension silently inert.
60
+ *
61
+ * Resolving once at startup keeps the property that matters. Every later
62
+ * operation traverses only real directories, so there is no user symlink left
63
+ * in the path to swap; repointing `~/.pi` afterwards cannot redirect this
64
+ * process. It is a pin, not a bypass.
65
+ *
66
+ * The root may not exist yet on a first run, so resolve the deepest ancestor
67
+ * that does and re-apply the remainder.
68
+ */
69
+ export function resolveRelayRoot(root: string): string {
70
+ const absolute = path.resolve(root);
71
+ const tail: string[] = [];
72
+ let current = absolute;
73
+ for (;;) {
74
+ try {
75
+ return path.join(fs.realpathSync(current), ...tail);
76
+ } catch (error) {
77
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
78
+ const parent = path.dirname(current);
79
+ // Filesystem root does not exist: nothing sane left to resolve against.
80
+ if (parent === current) return absolute;
81
+ tail.unshift(path.basename(current));
82
+ current = parent;
83
+ }
84
+ }
85
+ }
86
+
50
87
  export function ensureRoot(root: string): void {
51
88
  const directory = openRelayRoot(root, true)!;
52
89
  directory.close();