@nicknisi/pi-relay 0.2.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 +17 -3
- package/core.ts +314 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +223 -0
- package/dist/core.test.d.ts +1 -0
- package/dist/core.test.js +358 -0
- package/dist/filesystem.d.ts +42 -0
- package/dist/filesystem.js +614 -0
- package/dist/index.js +105 -26
- package/dist/mailbox.d.ts +30 -8
- package/dist/mailbox.js +615 -126
- package/dist/registry.js +254 -70
- package/dist/relay.test.js +204 -2
- package/filesystem.ts +635 -0
- package/index.ts +101 -25
- package/mailbox.ts +557 -122
- package/package.json +19 -1
- package/registry.ts +213 -66
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,
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
+
claim = claimInbox(root, me);
|
|
208
234
|
}
|
|
209
235
|
catch {
|
|
210
236
|
return;
|
|
211
237
|
}
|
|
212
|
-
|
|
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
|
|
270
|
+
// a malformed delivery never blocks the rest of the claim
|
|
219
271
|
}
|
|
220
|
-
if (
|
|
272
|
+
if (accepted) {
|
|
273
|
+
noteDelivered(letter.id);
|
|
274
|
+
ack(fileToken);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
221
277
|
lastDeliveryFailureAt = Date.now();
|
|
222
|
-
// Not handed to the session —
|
|
223
|
-
//
|
|
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
|
-
|
|
281
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
227
282
|
}
|
|
228
283
|
catch {
|
|
229
|
-
//
|
|
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);
|
|
@@ -690,16 +778,7 @@ export default function relay(pi) {
|
|
|
690
778
|
}
|
|
691
779
|
/** Find an outgoing ask id by exact id or unique prefix (for cancel ergonomics). */
|
|
692
780
|
function resolveOutAskId(root, addr, idOrPrefix) {
|
|
693
|
-
|
|
694
|
-
try {
|
|
695
|
-
names = fs.readdirSync(path.join(root, `${addr}.asks`));
|
|
696
|
-
}
|
|
697
|
-
catch {
|
|
698
|
-
return undefined;
|
|
699
|
-
}
|
|
700
|
-
const ids = names
|
|
701
|
-
.filter((f) => f.startsWith('out-') && f.endsWith('.json'))
|
|
702
|
-
.map((f) => f.slice('out-'.length, -'.json'.length));
|
|
781
|
+
const ids = outgoingAskIds(root, addr);
|
|
703
782
|
if (ids.includes(idOrPrefix))
|
|
704
783
|
return idOrPrefix;
|
|
705
784
|
const matches = ids.filter((id) => id.startsWith(idOrPrefix));
|
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
|
-
* -
|
|
8
|
-
* the
|
|
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.
|
|
@@ -27,13 +29,17 @@ export interface Letter {
|
|
|
27
29
|
ts: number;
|
|
28
30
|
}
|
|
29
31
|
export declare const MAX_BODY_CHARS: number;
|
|
30
|
-
/** Atomic deposit: write to a tmp sibling, then rename into place. */
|
|
32
|
+
/** Atomic deposit: write to a random exclusive tmp sibling, then rename into place. */
|
|
31
33
|
export declare function deposit(root: string, toAddr: string, letter: Letter): void;
|
|
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
|
|
36
|
-
*
|
|
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;
|
|
@@ -44,10 +50,24 @@ export declare function unreadCount(root: string, addr: string): number;
|
|
|
44
50
|
* a process alive.
|
|
45
51
|
*/
|
|
46
52
|
export declare function watchInbox(root: string, addr: string, onMail: () => void): () => void;
|
|
53
|
+
export interface InboxClaim {
|
|
54
|
+
claimToken: string;
|
|
55
|
+
fileTokens: string[];
|
|
56
|
+
}
|
|
57
|
+
/** Atomically detach the current non-empty inbox and return its durable tokens. */
|
|
58
|
+
export declare function claimInbox(root: string, addr: string): InboxClaim | null;
|
|
59
|
+
/** Enumerate durable claims left by this or a crashed consumer. */
|
|
60
|
+
export declare function recoverInboxClaims(root: string, addr: string): InboxClaim[];
|
|
61
|
+
/** Read and validate one claimed letter without exposing or following a path. */
|
|
62
|
+
export declare function readClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): Letter | null;
|
|
63
|
+
/** Permanently acknowledge/delete one claimed letter. Idempotent. */
|
|
64
|
+
export declare function ackClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
65
|
+
/** Atomically return one claimed letter to the current inbox. */
|
|
66
|
+
export declare function requeueClaimedLetter(root: string, addr: string, claimToken: string, fileToken: string): boolean;
|
|
47
67
|
/**
|
|
48
68
|
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
49
|
-
* the letter to vanish
|
|
50
|
-
*
|
|
69
|
+
* the exact letter to vanish from both the live inbox and durable claims.
|
|
70
|
+
* 'delivered' means the receiver acknowledged it, not merely claimed it.
|
|
51
71
|
*/
|
|
52
72
|
export declare function awaitReceipt(root: string, toAddr: string, letter: Letter, timeoutMs?: number): Promise<'delivered' | 'queued'>;
|
|
53
73
|
export interface OutAsk {
|
|
@@ -82,3 +102,5 @@ export declare function readAudit(root: string, limit?: number): AuditRecord[];
|
|
|
82
102
|
export declare function resolveAskByRef(root: string, addr: string, replyTo: string): Letter | null;
|
|
83
103
|
/** Asks we have received and not yet answered, oldest first. */
|
|
84
104
|
export declare function pendingAsks(root: string, addr: string): Letter[];
|
|
105
|
+
/** Outgoing ask ids, used by the Pi entry point for prefix resolution. */
|
|
106
|
+
export declare function outgoingAskIds(root: string, addr: string): string[];
|