@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 +1 -1
- package/dist/index.js +130 -25
- package/dist/mailbox.d.ts +11 -5
- package/dist/mailbox.js +11 -5
- package/dist/registry.d.ts +19 -0
- package/dist/registry.js +39 -0
- package/dist/relay.test.js +62 -2
- package/index.ts +126 -24
- package/mailbox.ts +11 -5
- package/package.json +1 -1
- package/registry.ts +37 -0
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
|
-
- **
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
248
|
+
claim = claimInbox(root, me);
|
|
208
249
|
}
|
|
209
250
|
catch {
|
|
210
251
|
return;
|
|
211
252
|
}
|
|
212
|
-
|
|
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
|
|
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
|
-
|
|
291
|
+
else {
|
|
221
292
|
lastDeliveryFailureAt = Date.now();
|
|
222
|
-
// Not handed to the session —
|
|
223
|
-
//
|
|
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
|
-
|
|
296
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
227
297
|
}
|
|
228
298
|
catch {
|
|
229
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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) :
|
|
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
|
-
* -
|
|
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.
|
|
@@ -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
|
|
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;
|
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
|
-
* -
|
|
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.
|
|
@@ -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
|
|
84
|
-
*
|
|
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');
|
package/dist/registry.d.ts
CHANGED
|
@@ -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();
|
package/dist/relay.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
claim = claimInbox(root, me);
|
|
275
321
|
} catch {
|
|
276
322
|
return;
|
|
277
323
|
}
|
|
278
|
-
|
|
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
|
|
352
|
+
// a malformed delivery never blocks the rest of the claim
|
|
284
353
|
}
|
|
285
|
-
if (
|
|
354
|
+
if (accepted) {
|
|
355
|
+
noteDelivered(letter.id);
|
|
356
|
+
ack(fileToken);
|
|
357
|
+
} else {
|
|
286
358
|
lastDeliveryFailureAt = Date.now();
|
|
287
|
-
// Not handed to the session —
|
|
288
|
-
//
|
|
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
|
-
|
|
362
|
+
requeueClaimedLetter(root, me, claimToken, fileToken);
|
|
292
363
|
} catch {
|
|
293
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
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) :
|
|
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
|
-
* -
|
|
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.
|
|
@@ -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
|
|
107
|
-
*
|
|
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.
|
|
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();
|