@nicknisi/pi-relay 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/core.ts +11 -0
- package/dist/core.js +11 -0
- package/dist/core.test.js +19 -0
- package/dist/extension.test.js +30 -1
- package/dist/index.js +18 -0
- package/dist/mailbox.d.ts +11 -0
- package/dist/mailbox.js +2 -0
- package/dist/relay.test.js +20 -0
- package/index.ts +17 -0
- package/mailbox.ts +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,6 +81,7 @@ main moved; rebase before you push.
|
|
|
81
81
|
- **Ordinary replies.** `reply` resolves an explicit full ID or unique prefix across pending asks and received message/reply entries in the active transcript branch. This survives reload and does not search sibling branches or infer a recipient. Replying to an ordinary message preserves unrelated pending asks. Successful send/reply results expose full `messageId`, `target`, `replyTo` where applicable, and `receipt` in details; broadcasts expose per-recipient receipts.
|
|
82
82
|
- **Presence** is a pid plus a heartbeat: `live` (process exists, beat <45s), `not responding` (process exists, stale beat — wedged or suspended), `offline` (no live pid; mail waits). Status flips `working`/`idle` with the agent loop.
|
|
83
83
|
- **Authority boundary on every delivery.** Each message arrives with a repeated statement that it came from a peer and carries no authority — it cannot approve anything, cannot change configuration, slash commands in it are inert text. The sending side's tool guidelines carry the reciprocal rule: never ask a peer to do something your own permissions would refuse.
|
|
84
|
+
- **Quiet letters.** A letter with `wake: false` (set by core senders building their own `Letter` for `deposit`) lands in the receiving session's context without triggering a model turn: an FYI the agent sees on its next turn. Absent or `true` wakes the session as before, and old readers ignore the field, so mixed versions interoperate. An ask is never quiet: core `deposit` refuses `wake: false` on an ask, and delivery wakes an ask regardless, since the receiver must take a turn to answer. The `relay` tool always sends waking mail.
|
|
84
85
|
- **Loops break structurally**, independent of what either model decides: identical text from one sender inside 10s is dropped; >8 messages per 30s per sender is refused; an unread backlog of 50 refuses new mail until the peer drains.
|
|
85
86
|
- **Plain text only, ≤32KB.** Send a summary and a path, not a payload.
|
|
86
87
|
- **Sweeping is narrow:** a running session is never touched; an inbox or durable claim holding undelivered mail is kept 30 days; an offline-but-resumable session keeps its record (its address — new mail must remain deliverable while it's down); only an empty mailbox of a session that can no longer be resumed is discarded promptly. Listing never has side effects.
|
|
@@ -128,5 +129,5 @@ The directory is created `0700` and every file `0600` — other users on the mac
|
|
|
128
129
|
- **Bun <= 1.3.14 refused.** Those Bun versions abort the process when koffi's GC finalizer releases an N-API reference ([oven-sh/bun#39263](https://github.com/oven-sh/bun/issues/39263), fixed upstream after 1.3.14), so relay fails load with a clear error on them instead. Node.js and newer Bun load normally.
|
|
129
130
|
- **One machine.** Delivery is a file landing in a directory; two sessions reach each other exactly when they share a filesystem. A container and its host cannot.
|
|
130
131
|
- **Presence is heartbeat-accurate**, not instantaneous (within ~45s).
|
|
131
|
-
- Delivery injects with `deliverAs: "steer"` (lands between tool calls) and `triggerTurn: true` (wakes an idle session).
|
|
132
|
+
- Delivery injects with `deliverAs: "steer"` (lands between tool calls) and `triggerTurn: true` (wakes an idle session), unless the letter sets `wake: false`, which appends to the session without taking a turn.
|
|
132
133
|
- Depends on pi extension APIs (`session_start`/`agent_start`/`agent_end` lifecycle events, `getSessionName`, `sendMessage` delivery modes) that could drift across pi versions.
|
package/core.ts
CHANGED
|
@@ -280,6 +280,17 @@ export function sweep(root: string, now: number = Date.now(), sessionExists?: (s
|
|
|
280
280
|
export function deposit(root: string, toAddr: string, letter: Letter): void {
|
|
281
281
|
assertAddress(toAddr);
|
|
282
282
|
assertLetter(letter);
|
|
283
|
+
// wake is validated on the write side only. The read path (isSafeLetter)
|
|
284
|
+
// tolerates a non-boolean wake, so a letter from a writer that never
|
|
285
|
+
// validated the field is still delivered (as waking mail: delivery only
|
|
286
|
+
// honours wake === false) instead of being discarded after drain already
|
|
287
|
+
// unlinked it.
|
|
288
|
+
if (letter.wake !== undefined && typeof letter.wake !== 'boolean') {
|
|
289
|
+
throw new TypeError(`Invalid relay letter wake flag: ${letter.wake}`);
|
|
290
|
+
}
|
|
291
|
+
if (letter.kind === 'ask' && letter.wake === false) {
|
|
292
|
+
throw new TypeError('A quiet ask (wake: false) is refused: the receiver takes no turn, so it could never answer');
|
|
293
|
+
}
|
|
283
294
|
depositInternal(root, toAddr, letter);
|
|
284
295
|
}
|
|
285
296
|
|
package/dist/core.js
CHANGED
|
@@ -200,6 +200,17 @@ export function sweep(root, now = Date.now(), sessionExists) {
|
|
|
200
200
|
export function deposit(root, toAddr, letter) {
|
|
201
201
|
assertAddress(toAddr);
|
|
202
202
|
assertLetter(letter);
|
|
203
|
+
// wake is validated on the write side only. The read path (isSafeLetter)
|
|
204
|
+
// tolerates a non-boolean wake, so a letter from a writer that never
|
|
205
|
+
// validated the field is still delivered (as waking mail: delivery only
|
|
206
|
+
// honours wake === false) instead of being discarded after drain already
|
|
207
|
+
// unlinked it.
|
|
208
|
+
if (letter.wake !== undefined && typeof letter.wake !== 'boolean') {
|
|
209
|
+
throw new TypeError(`Invalid relay letter wake flag: ${letter.wake}`);
|
|
210
|
+
}
|
|
211
|
+
if (letter.kind === 'ask' && letter.wake === false) {
|
|
212
|
+
throw new TypeError('A quiet ask (wake: false) is refused: the receiver takes no turn, so it could never answer');
|
|
213
|
+
}
|
|
203
214
|
depositInternal(root, toAddr, letter);
|
|
204
215
|
}
|
|
205
216
|
export function drain(root, addr) {
|
package/dist/core.test.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as path from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
7
|
import * as core from './core.js';
|
|
8
|
+
import { deposit as mailboxDeposit } from './mailbox.js';
|
|
8
9
|
const relayDir = path.dirname(fileURLToPath(import.meta.url));
|
|
9
10
|
const repoRoot = path.resolve(relayDir, '../..');
|
|
10
11
|
const packageJson = JSON.parse(fs.readFileSync(path.join(relayDir, 'package.json'), 'utf8'));
|
|
@@ -313,6 +314,24 @@ describe('/core filesystem boundary', () => {
|
|
|
313
314
|
expect(fs.readdirSync(base)).toEqual(['relay']);
|
|
314
315
|
});
|
|
315
316
|
});
|
|
317
|
+
it('threads wake through core deposit and rejects a non-boolean flag and a quiet ask', () => {
|
|
318
|
+
const root = path.join(fixture(), 'relay');
|
|
319
|
+
const addr = core.deriveAddr('/receiver', 'session');
|
|
320
|
+
core.deposit(root, addr, letter({ id: 'wake-core', kind: 'message', wake: false }));
|
|
321
|
+
expect(core.drain(root, addr).map((l) => [l.id, l.wake])).toEqual([['wake-core', false]]);
|
|
322
|
+
expect(() => core.deposit(root, addr, letter({ kind: 'message', wake: 'no' }))).toThrow(TypeError);
|
|
323
|
+
// A quiet ask could never be answered: the receiver takes no turn.
|
|
324
|
+
expect(() => core.deposit(root, addr, letter({ kind: 'ask', wake: false }))).toThrow(TypeError);
|
|
325
|
+
});
|
|
326
|
+
it('drains rather than discards a letter whose wake flag is not boolean', () => {
|
|
327
|
+
const root = path.join(fixture(), 'relay');
|
|
328
|
+
const addr = core.deriveAddr('/receiver', 'session');
|
|
329
|
+
// An old writer stringifies the whole letter without validating wake, so a
|
|
330
|
+
// non-boolean flag can land on disk. Drain unlinks before parsing; the read
|
|
331
|
+
// path must deliver the letter (as waking mail), not silently drop it.
|
|
332
|
+
mailboxDeposit(root, addr, letter({ id: 'wake-odd', kind: 'message', wake: 'no' }));
|
|
333
|
+
expect(core.drain(root, addr).map((l) => l.id)).toEqual(['wake-odd']);
|
|
334
|
+
});
|
|
316
335
|
it('keeps the Pi extension entry importable', async () => {
|
|
317
336
|
const entry = await import('./index.js');
|
|
318
337
|
expect(typeof entry.default).toBe('function');
|
package/dist/extension.test.js
CHANGED
|
@@ -45,6 +45,7 @@ function start() {
|
|
|
45
45
|
const handlers = new Map();
|
|
46
46
|
let execute;
|
|
47
47
|
let branch = [];
|
|
48
|
+
const sends = [];
|
|
48
49
|
const ctx = {
|
|
49
50
|
cwd: '/work',
|
|
50
51
|
sessionManager: { getSessionId: () => 'self', getCwd: () => '/work', getBranch: () => branch },
|
|
@@ -60,7 +61,8 @@ function start() {
|
|
|
60
61
|
registerCommand: () => { },
|
|
61
62
|
registerMessageRenderer: () => { },
|
|
62
63
|
registerEntryRenderer: () => { },
|
|
63
|
-
sendMessage: (message) => {
|
|
64
|
+
sendMessage: (message, options) => {
|
|
65
|
+
sends.push({ message, options });
|
|
64
66
|
branch.push({ ...message, type: 'custom_message' });
|
|
65
67
|
},
|
|
66
68
|
};
|
|
@@ -76,6 +78,7 @@ function start() {
|
|
|
76
78
|
branch = entries;
|
|
77
79
|
},
|
|
78
80
|
emit,
|
|
81
|
+
sends,
|
|
79
82
|
};
|
|
80
83
|
}
|
|
81
84
|
function delivery(l) {
|
|
@@ -164,6 +167,32 @@ it('restores an offline queued letter and emits an exact durable receipt', async
|
|
|
164
167
|
const { awaitReceipt } = await import('./mailbox.js');
|
|
165
168
|
expect(await awaitReceipt(root, self, l, 1800)).toBe('delivered');
|
|
166
169
|
});
|
|
170
|
+
it('delivers wake:false mail without triggering a turn, waking mail with one, and an ask always wakes', async () => {
|
|
171
|
+
const root = fixture();
|
|
172
|
+
const p = peer();
|
|
173
|
+
writeRecord(root, p);
|
|
174
|
+
const self = deriveAddr('/work', 'self');
|
|
175
|
+
const from = { addr: p.addr, name: p.name, cwd: p.cwd };
|
|
176
|
+
const quiet = { id: 'quiet-letter', kind: 'message', body: 'fyi only', ts: Date.now(), wake: false, from };
|
|
177
|
+
const loud = { id: 'loud-letter', kind: 'message', body: 'wake up', ts: Date.now() + 1, from };
|
|
178
|
+
// The mailbox layer does not validate wake, so a quiet ask can land on
|
|
179
|
+
// disk; delivery must wake it anyway or it would never be answered.
|
|
180
|
+
const quietAsk = { id: 'quiet-ask', kind: 'ask', body: 'answer me', ts: Date.now() + 2, wake: false, from };
|
|
181
|
+
deposit(root, self, quiet);
|
|
182
|
+
deposit(root, self, loud);
|
|
183
|
+
deposit(root, self, quietAsk);
|
|
184
|
+
const host = start();
|
|
185
|
+
const { awaitReceipt } = await import('./mailbox.js');
|
|
186
|
+
// All three letters are acked, so all landed in the session's context.
|
|
187
|
+
expect(await awaitReceipt(root, self, quiet, 3000)).toBe('delivered');
|
|
188
|
+
expect(await awaitReceipt(root, self, loud, 3000)).toBe('delivered');
|
|
189
|
+
expect(await awaitReceipt(root, self, quietAsk, 3000)).toBe('delivered');
|
|
190
|
+
const byId = (id) => host.sends.find((s) => s.message.details?.id === id);
|
|
191
|
+
expect(byId(quiet.id).options).toEqual({ triggerTurn: false });
|
|
192
|
+
expect(byId(loud.id).options).toEqual({ triggerTurn: true, deliverAs: 'steer' });
|
|
193
|
+
expect(byId(quietAsk.id).options).toEqual({ triggerTurn: true, deliverAs: 'steer' });
|
|
194
|
+
expect(pendingAsks(root, self).map((l) => l.id)).toEqual([quietAsk.id]);
|
|
195
|
+
});
|
|
167
196
|
it('settles a waiting ask on shutdown and clears tracking for the original sender', async () => {
|
|
168
197
|
const root = fixture();
|
|
169
198
|
const p = peer();
|
package/dist/index.js
CHANGED
|
@@ -201,6 +201,24 @@ export default function relay(pi) {
|
|
|
201
201
|
display: true,
|
|
202
202
|
details,
|
|
203
203
|
};
|
|
204
|
+
// wake === false: an FYI letter. It must land in the session's context
|
|
205
|
+
// (triggerTurn: false appends to the session, or queues until the
|
|
206
|
+
// current turn ends when the agent is streaming) without taking a model
|
|
207
|
+
// turn. Absent or true keeps the wake path below. An ask always wakes,
|
|
208
|
+
// whatever its flag says (core deposit refuses quiet asks, but the
|
|
209
|
+
// mailbox layer does not validate): without a turn the receiver could
|
|
210
|
+
// never answer and the asker would wait out its timeout.
|
|
211
|
+
if (letter.wake === false && letter.kind !== 'ask') {
|
|
212
|
+
try {
|
|
213
|
+
pi.sendMessage(message, { triggerTurn: false });
|
|
214
|
+
auditDelivery('deliver');
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
auditDelivery('deliver-failed');
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
204
222
|
// steer lands between tool calls mid-run; triggerTurn wakes an idle
|
|
205
223
|
// session. A busy agent rejects triggerTurn ("Agent is already
|
|
206
224
|
// processing") — fall back to queueing for the next turn instead of
|
package/dist/mailbox.d.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface Letter {
|
|
|
26
26
|
kind: LetterKind;
|
|
27
27
|
body: string;
|
|
28
28
|
replyTo?: string;
|
|
29
|
+
/**
|
|
30
|
+
* `false` delivers quietly: the letter still lands in the receiving
|
|
31
|
+
* session's context, but does not trigger a model turn. Absent or `true`
|
|
32
|
+
* wakes the session as before. An ask is never quiet: core `deposit`
|
|
33
|
+
* refuses `wake: false` on kind `ask`, and delivery wakes an ask
|
|
34
|
+
* regardless, because without a turn the receiver could never answer.
|
|
35
|
+
* Backward compatible: letters are stored as whole-JSON files and readers
|
|
36
|
+
* only require `id`/`body`, so old readers ignore the field and old
|
|
37
|
+
* writers never set it.
|
|
38
|
+
*/
|
|
39
|
+
wake?: boolean;
|
|
29
40
|
ts: number;
|
|
30
41
|
}
|
|
31
42
|
export declare const MAX_BODY_CHARS: number;
|
package/dist/mailbox.js
CHANGED
|
@@ -372,6 +372,8 @@ export function readClaimedLetter(root, addr, claimToken, fileToken) {
|
|
|
372
372
|
relay.close();
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
|
+
/** Content digest for delivery proof. `wake` is delivery mode, not content,
|
|
376
|
+
* and is excluded so old and new receivers compute matching digests. */
|
|
375
377
|
function letterDigest(letter) {
|
|
376
378
|
return createHash('sha256')
|
|
377
379
|
.update(JSON.stringify([
|
package/dist/relay.test.js
CHANGED
|
@@ -170,6 +170,26 @@ describe('mailbox', () => {
|
|
|
170
170
|
expect(drain(root, addr)).toEqual([]); // nothing twice
|
|
171
171
|
expect(unreadCount(root, addr)).toBe(0);
|
|
172
172
|
});
|
|
173
|
+
it('wake survives deposit intact, stays absent when unset, and does not break delivery proof', async () => {
|
|
174
|
+
const root = tmpRoot();
|
|
175
|
+
const addr = 'inbox0000006';
|
|
176
|
+
deposit(root, addr, letter({ id: 'quiet-1', ts: 1000, wake: false }));
|
|
177
|
+
deposit(root, addr, letter({ id: 'plain-1', ts: 2000 }));
|
|
178
|
+
const drained = drain(root, addr);
|
|
179
|
+
expect(drained.map((l) => [l.id, l.wake])).toEqual([
|
|
180
|
+
['quiet-1', false],
|
|
181
|
+
['plain-1', undefined],
|
|
182
|
+
]);
|
|
183
|
+
expect('wake' in drained[1]).toBe(false); // old writers never set it; nothing invents it
|
|
184
|
+
// The durable claim flow preserves the field, and the acknowledgement
|
|
185
|
+
// digest (which excludes wake) still proves delivery to the sender.
|
|
186
|
+
const quiet = letter({ id: 'quiet-2', ts: 3000, wake: false });
|
|
187
|
+
deposit(root, addr, quiet);
|
|
188
|
+
const claim = claimInbox(root, addr);
|
|
189
|
+
expect(readClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])?.wake).toBe(false);
|
|
190
|
+
expect(ackClaimedLetter(root, addr, claim.claimToken, claim.fileTokens[0])).toBe(true);
|
|
191
|
+
expect(await awaitReceipt(root, addr, quiet, 400)).toBe('delivered');
|
|
192
|
+
});
|
|
173
193
|
it('discards corrupt letters without poisoning future drains', () => {
|
|
174
194
|
const root = tmpRoot();
|
|
175
195
|
const addr = 'inbox0000002';
|
package/index.ts
CHANGED
|
@@ -270,6 +270,23 @@ export default function relay(pi: ExtensionAPI) {
|
|
|
270
270
|
display: true,
|
|
271
271
|
details,
|
|
272
272
|
};
|
|
273
|
+
// wake === false: an FYI letter. It must land in the session's context
|
|
274
|
+
// (triggerTurn: false appends to the session, or queues until the
|
|
275
|
+
// current turn ends when the agent is streaming) without taking a model
|
|
276
|
+
// turn. Absent or true keeps the wake path below. An ask always wakes,
|
|
277
|
+
// whatever its flag says (core deposit refuses quiet asks, but the
|
|
278
|
+
// mailbox layer does not validate): without a turn the receiver could
|
|
279
|
+
// never answer and the asker would wait out its timeout.
|
|
280
|
+
if (letter.wake === false && letter.kind !== 'ask') {
|
|
281
|
+
try {
|
|
282
|
+
pi.sendMessage(message, { triggerTurn: false });
|
|
283
|
+
auditDelivery('deliver');
|
|
284
|
+
return true;
|
|
285
|
+
} catch {
|
|
286
|
+
auditDelivery('deliver-failed');
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
273
290
|
// steer lands between tool calls mid-run; triggerTurn wakes an idle
|
|
274
291
|
// session. A busy agent rejects triggerTurn ("Agent is already
|
|
275
292
|
// processing") — fall back to queueing for the next turn instead of
|
package/mailbox.ts
CHANGED
|
@@ -35,6 +35,17 @@ export interface Letter {
|
|
|
35
35
|
kind: LetterKind;
|
|
36
36
|
body: string;
|
|
37
37
|
replyTo?: string;
|
|
38
|
+
/**
|
|
39
|
+
* `false` delivers quietly: the letter still lands in the receiving
|
|
40
|
+
* session's context, but does not trigger a model turn. Absent or `true`
|
|
41
|
+
* wakes the session as before. An ask is never quiet: core `deposit`
|
|
42
|
+
* refuses `wake: false` on kind `ask`, and delivery wakes an ask
|
|
43
|
+
* regardless, because without a turn the receiver could never answer.
|
|
44
|
+
* Backward compatible: letters are stored as whole-JSON files and readers
|
|
45
|
+
* only require `id`/`body`, so old readers ignore the field and old
|
|
46
|
+
* writers never set it.
|
|
47
|
+
*/
|
|
48
|
+
wake?: boolean;
|
|
38
49
|
ts: number;
|
|
39
50
|
}
|
|
40
51
|
|
|
@@ -375,6 +386,8 @@ interface DeliveryAcknowledgement {
|
|
|
375
386
|
acknowledgedAt: number;
|
|
376
387
|
}
|
|
377
388
|
|
|
389
|
+
/** Content digest for delivery proof. `wake` is delivery mode, not content,
|
|
390
|
+
* and is excluded so old and new receivers compute matching digests. */
|
|
378
391
|
function letterDigest(letter: Letter): string {
|
|
379
392
|
return createHash('sha256')
|
|
380
393
|
.update(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicknisi/pi-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|