@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 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');
@@ -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([
@@ -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.5.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",