@nicknisi/pi-relay 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,7 +18,7 @@ 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
23
  import { claimAlias, deriveAddr, ensureRoot, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeRecord, } from './registry.js';
24
24
  function toolResult(text, details = {}) {
@@ -99,10 +99,23 @@ export default function relay(pi) {
99
99
  let unwatch;
100
100
  let heartbeat;
101
101
  const askWaiters = new Map();
102
- // Backoff while the session can't accept mail: a re-deposited letter
102
+ // Backoff while the session can't accept mail: a requeued letter
103
103
  // re-fires the watcher, and without this a failing sendMessage would spin
104
- // drain→fail→re-deposit at watch speed.
104
+ // claim→fail→requeue at watch speed.
105
105
  let lastDeliveryFailureAt = 0;
106
+ // Letter ids this session has already accepted. Claims make redelivery
107
+ // possible (a crash between delivery and ack, or an ack failure after a
108
+ // successful send); without dedupe the model would see the body twice.
109
+ // Seeded from the transcript on session_start so recovery after a crash
110
+ // doesn't redeliver what the previous process already handed to the agent.
111
+ const deliveredIds = new Set();
112
+ const DELIVERED_IDS_CAP = 512;
113
+ const noteDelivered = (id) => {
114
+ deliveredIds.delete(id); // refresh recency
115
+ deliveredIds.add(id);
116
+ if (deliveredIds.size > DELIVERED_IDS_CAP)
117
+ deliveredIds.delete(deliveredIds.values().next().value);
118
+ };
106
119
  // Presence watch: addr → last observed presence. A poller surfaces
107
120
  // transitions as a notification message (no full turn wake).
108
121
  const watched = new Map();
@@ -122,7 +135,13 @@ export default function relay(pi) {
122
135
  function deliver(letter) {
123
136
  // Audited after the delivery attempts below: a 'deliver' record means the
124
137
  // session actually accepted the letter; refusal records 'deliver-failed'
125
- // (the caller re-deposits, so logging here would duplicate every retry).
138
+ // (the caller requeues, so logging here would duplicate every retry).
139
+ // Note pi.sendMessage is fire-and-forget: asynchronous failures are
140
+ // swallowed by the runtime (emitted as extension errors, never rethrown),
141
+ // so only synchronous refusals — a disposed/uninitialized runtime during
142
+ // reload or shutdown — reach these catches. That is exactly the case
143
+ // where the letter never enters the transcript: async rejections from the
144
+ // triggerTurn path happen after the message is already in context.
126
145
  const auditDelivery = (event) => appendAudit(root, {
127
146
  ts: Date.now(),
128
147
  event,
@@ -186,7 +205,7 @@ export default function relay(pi) {
186
205
  }
187
206
  catch {
188
207
  // session is shutting down or otherwise undeliverable — the caller
189
- // re-deposits the letter so a future drain retries
208
+ // requeues the letter so a future drain retries
190
209
  auditDelivery('deliver-failed');
191
210
  return false;
192
211
  }
@@ -196,40 +215,86 @@ export default function relay(pi) {
196
215
  function checkInbox() {
197
216
  if (!self)
198
217
  return;
218
+ const me = self.addr;
199
219
  if (Date.now() - lastDeliveryFailureAt < 5000)
200
220
  return;
201
- // Refuse mode: never drain. Letters stay on disk (receipts honestly read
221
+ // Refuse mode: never claim. Letters stay on disk (receipts honestly read
202
222
  // 'queued') instead of being silently consumed and dropped.
203
223
  if (!inboundAccepts())
204
224
  return;
205
- let letters;
225
+ // Claim, not drain: the inbox is detached atomically and each letter is
226
+ // only acked (deleted) once the session has accepted it. A claimed but
227
+ // unacked letter still reads 'queued' to the sender's receipt, survives
228
+ // a crash (recoverInboxClaims on the next session_start), and can be
229
+ // requeued for retry — unlink-as-read could silently lose it while the
230
+ // sender's receipt reported "delivered".
231
+ let claim;
206
232
  try {
207
- letters = drain(root, self.addr);
233
+ claim = claimInbox(root, me);
208
234
  }
209
235
  catch {
210
236
  return;
211
237
  }
212
- for (const letter of letters) {
238
+ if (!claim)
239
+ return;
240
+ const { claimToken, fileTokens } = claim;
241
+ const ack = (fileToken) => {
242
+ try {
243
+ ackClaimedLetter(root, me, claimToken, fileToken);
244
+ }
245
+ catch {
246
+ // ack failure leaves the letter claimed; recovery requeues it and
247
+ // deliveredIds suppresses the duplicate
248
+ }
249
+ };
250
+ for (const fileToken of fileTokens) {
251
+ let letter = null;
252
+ try {
253
+ letter = readClaimedLetter(root, me, claimToken, fileToken);
254
+ }
255
+ catch {
256
+ // transient read failure — keep it claimed rather than discarding
257
+ continue;
258
+ }
259
+ if (!letter || deliveredIds.has(letter.id)) {
260
+ // corrupt letters are discarded like drain does; already-accepted
261
+ // letters are redeliveries (crash between delivery and ack)
262
+ ack(fileToken);
263
+ continue;
264
+ }
213
265
  let accepted = false;
214
266
  try {
215
267
  accepted = deliver(letter);
216
268
  }
217
269
  catch {
218
- // a malformed delivery never blocks the rest of the drain
270
+ // a malformed delivery never blocks the rest of the claim
271
+ }
272
+ if (accepted) {
273
+ noteDelivered(letter.id);
274
+ ack(fileToken);
219
275
  }
220
- if (!accepted) {
276
+ else {
221
277
  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".
278
+ // Not handed to the session — atomically return it to the live inbox
279
+ // so the watch/poll retries it.
225
280
  try {
226
- deposit(root, self.addr, letter);
281
+ requeueClaimedLetter(root, me, claimToken, fileToken);
227
282
  }
228
283
  catch {
229
- // inbox dir unwritable; nothing more we can do
284
+ // stays claimed; the next session_start recovers it
230
285
  }
231
286
  }
232
287
  }
288
+ // claimInbox renamed the watched inbox dir away and created a fresh one,
289
+ // so the fs.watch is now on a detached directory. Re-arm on the live
290
+ // inbox (the 3s poll covers anything deposited in between).
291
+ try {
292
+ unwatch?.();
293
+ unwatch = watchInbox(root, me, checkInbox);
294
+ }
295
+ catch {
296
+ // watch failure is non-fatal; the old poll timer died with the old watch
297
+ }
233
298
  }
234
299
  function myFrom() {
235
300
  const s = self;
@@ -363,6 +428,29 @@ export default function relay(pi) {
363
428
  writeRecord(root, self);
364
429
  const resumable = collectResumableSessionIds();
365
430
  sweep(root, Date.now(), (id) => resumable.has(id));
431
+ // Seed redelivery dedupe from the transcript: a letter delivered by a
432
+ // previous process but never acked (crash between delivery and ack)
433
+ // will be recovered below and must not be handed to the model twice.
434
+ for (const entry of ctx.sessionManager.getEntries()) {
435
+ if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE)
436
+ continue;
437
+ const id = entry.details?.id;
438
+ if (typeof id === 'string')
439
+ noteDelivered(id);
440
+ }
441
+ // Recover letters stranded in durable claims by a crash mid-delivery:
442
+ // return them to the live inbox so the normal watch/poll retries them.
443
+ // Runs before the watch is armed so requeues land in the watched inbox.
444
+ for (const stranded of recoverInboxClaims(root, self.addr)) {
445
+ for (const fileToken of stranded.fileTokens) {
446
+ try {
447
+ requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
448
+ }
449
+ catch {
450
+ // stays claimed; the next session_start retries recovery
451
+ }
452
+ }
453
+ }
366
454
  unwatch?.();
367
455
  unwatch = watchInbox(root, self.addr, checkInbox);
368
456
  heartbeat = setInterval(() => writeSelf({}), 15_000);
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');
@@ -8,7 +8,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
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
13
  import { claimAlias, clearAlias, deriveAddr, isValidAliasName, listAliases, listRecords, presenceOf, readAlias, readRecord, sweep, writeAlias, writeRecord, } from './registry.js';
14
14
  const require = createRequire(import.meta.url);
@@ -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';
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,
@@ -169,10 +173,22 @@ export default function relay(pi: ExtensionAPI) {
169
173
  let unwatch: (() => void) | undefined;
170
174
  let heartbeat: ReturnType<typeof setInterval> | undefined;
171
175
  const askWaiters = new Map<string, (outcome: AskOutcome) => void>();
172
- // Backoff while the session can't accept mail: a re-deposited letter
176
+ // Backoff while the session can't accept mail: a requeued letter
173
177
  // re-fires the watcher, and without this a failing sendMessage would spin
174
- // drain→fail→re-deposit at watch speed.
178
+ // claim→fail→requeue at watch speed.
175
179
  let lastDeliveryFailureAt = 0;
180
+ // Letter ids this session has already accepted. Claims make redelivery
181
+ // possible (a crash between delivery and ack, or an ack failure after a
182
+ // successful send); without dedupe the model would see the body twice.
183
+ // Seeded from the transcript on session_start so recovery after a crash
184
+ // doesn't redeliver what the previous process already handed to the agent.
185
+ const deliveredIds = new Set<string>();
186
+ const DELIVERED_IDS_CAP = 512;
187
+ const noteDelivered = (id: string): void => {
188
+ deliveredIds.delete(id); // refresh recency
189
+ deliveredIds.add(id);
190
+ if (deliveredIds.size > DELIVERED_IDS_CAP) deliveredIds.delete(deliveredIds.values().next().value!);
191
+ };
176
192
  // Presence watch: addr → last observed presence. A poller surfaces
177
193
  // transitions as a notification message (no full turn wake).
178
194
  const watched = new Map<string, Presence>();
@@ -192,7 +208,13 @@ export default function relay(pi: ExtensionAPI) {
192
208
  function deliver(letter: Letter): boolean {
193
209
  // Audited after the delivery attempts below: a 'deliver' record means the
194
210
  // session actually accepted the letter; refusal records 'deliver-failed'
195
- // (the caller re-deposits, so logging here would duplicate every retry).
211
+ // (the caller requeues, so logging here would duplicate every retry).
212
+ // Note pi.sendMessage is fire-and-forget: asynchronous failures are
213
+ // swallowed by the runtime (emitted as extension errors, never rethrown),
214
+ // so only synchronous refusals — a disposed/uninitialized runtime during
215
+ // reload or shutdown — reach these catches. That is exactly the case
216
+ // where the letter never enters the transcript: async rejections from the
217
+ // triggerTurn path happen after the message is already in context.
196
218
  const auditDelivery = (event: 'deliver' | 'deliver-failed') =>
197
219
  appendAudit(root, {
198
220
  ts: Date.now(),
@@ -255,7 +277,7 @@ export default function relay(pi: ExtensionAPI) {
255
277
  return true;
256
278
  } catch {
257
279
  // session is shutting down or otherwise undeliverable — the caller
258
- // re-deposits the letter so a future drain retries
280
+ // requeues the letter so a future drain retries
259
281
  auditDelivery('deliver-failed');
260
282
  return false;
261
283
  }
@@ -265,35 +287,76 @@ export default function relay(pi: ExtensionAPI) {
265
287
 
266
288
  function checkInbox(): void {
267
289
  if (!self) return;
290
+ const me = self.addr;
268
291
  if (Date.now() - lastDeliveryFailureAt < 5000) return;
269
- // Refuse mode: never drain. Letters stay on disk (receipts honestly read
292
+ // Refuse mode: never claim. Letters stay on disk (receipts honestly read
270
293
  // 'queued') instead of being silently consumed and dropped.
271
294
  if (!inboundAccepts()) return;
272
- let letters: Letter[];
295
+ // Claim, not drain: the inbox is detached atomically and each letter is
296
+ // only acked (deleted) once the session has accepted it. A claimed but
297
+ // unacked letter still reads 'queued' to the sender's receipt, survives
298
+ // a crash (recoverInboxClaims on the next session_start), and can be
299
+ // requeued for retry — unlink-as-read could silently lose it while the
300
+ // sender's receipt reported "delivered".
301
+ let claim: ReturnType<typeof claimInbox>;
273
302
  try {
274
- letters = drain(root, self.addr);
303
+ claim = claimInbox(root, me);
275
304
  } catch {
276
305
  return;
277
306
  }
278
- for (const letter of letters) {
307
+ if (!claim) return;
308
+ const { claimToken, fileTokens } = claim;
309
+ const ack = (fileToken: string) => {
310
+ try {
311
+ ackClaimedLetter(root, me, claimToken, fileToken);
312
+ } catch {
313
+ // ack failure leaves the letter claimed; recovery requeues it and
314
+ // deliveredIds suppresses the duplicate
315
+ }
316
+ };
317
+ for (const fileToken of fileTokens) {
318
+ let letter: Letter | null = null;
319
+ try {
320
+ letter = readClaimedLetter(root, me, claimToken, fileToken);
321
+ } catch {
322
+ // transient read failure — keep it claimed rather than discarding
323
+ continue;
324
+ }
325
+ if (!letter || deliveredIds.has(letter.id)) {
326
+ // corrupt letters are discarded like drain does; already-accepted
327
+ // letters are redeliveries (crash between delivery and ack)
328
+ ack(fileToken);
329
+ continue;
330
+ }
279
331
  let accepted = false;
280
332
  try {
281
333
  accepted = deliver(letter);
282
334
  } catch {
283
- // a malformed delivery never blocks the rest of the drain
335
+ // a malformed delivery never blocks the rest of the claim
284
336
  }
285
- if (!accepted) {
337
+ if (accepted) {
338
+ noteDelivered(letter.id);
339
+ ack(fileToken);
340
+ } else {
286
341
  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".
342
+ // Not handed to the session — atomically return it to the live inbox
343
+ // so the watch/poll retries it.
290
344
  try {
291
- deposit(root, self.addr, letter);
345
+ requeueClaimedLetter(root, me, claimToken, fileToken);
292
346
  } catch {
293
- // inbox dir unwritable; nothing more we can do
347
+ // stays claimed; the next session_start recovers it
294
348
  }
295
349
  }
296
350
  }
351
+ // claimInbox renamed the watched inbox dir away and created a fresh one,
352
+ // so the fs.watch is now on a detached directory. Re-arm on the live
353
+ // inbox (the 3s poll covers anything deposited in between).
354
+ try {
355
+ unwatch?.();
356
+ unwatch = watchInbox(root, me, checkInbox);
357
+ } catch {
358
+ // watch failure is non-fatal; the old poll timer died with the old watch
359
+ }
297
360
  }
298
361
 
299
362
  function myFrom(): { addr: string; name: string; cwd: string } {
@@ -432,6 +495,26 @@ export default function relay(pi: ExtensionAPI) {
432
495
  writeRecord(root, self);
433
496
  const resumable = collectResumableSessionIds();
434
497
  sweep(root, Date.now(), (id) => resumable.has(id));
498
+ // Seed redelivery dedupe from the transcript: a letter delivered by a
499
+ // previous process but never acked (crash between delivery and ack)
500
+ // will be recovered below and must not be handed to the model twice.
501
+ for (const entry of ctx.sessionManager.getEntries()) {
502
+ if (entry.type !== 'custom_message' || entry.customType !== DELIVERY_TYPE) continue;
503
+ const id = (entry.details as { id?: unknown } | undefined)?.id;
504
+ if (typeof id === 'string') noteDelivered(id);
505
+ }
506
+ // Recover letters stranded in durable claims by a crash mid-delivery:
507
+ // return them to the live inbox so the normal watch/poll retries them.
508
+ // Runs before the watch is armed so requeues land in the watched inbox.
509
+ for (const stranded of recoverInboxClaims(root, self.addr)) {
510
+ for (const fileToken of stranded.fileTokens) {
511
+ try {
512
+ requeueClaimedLetter(root, self.addr, stranded.claimToken, fileToken);
513
+ } catch {
514
+ // stays claimed; the next session_start retries recovery
515
+ }
516
+ }
517
+ }
435
518
  unwatch?.();
436
519
  unwatch = watchInbox(root, self.addr, checkInbox);
437
520
  heartbeat = setInterval(() => writeSelf({}), 15_000);
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.1",
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",