@nicknisi/pi-relay 0.4.1 → 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
@@ -19,6 +19,26 @@ import { OutboundPolicy, claimInbox, deposit, deriveAddr, type Letter } from '@n
19
19
 
20
20
  The `@nicknisi/pi-relay/core` subpath exports a narrow record and alias registry, mailbox and ask/audit operations, presence and sweep utilities, policy guards and constants, and their public types. Filesystem-facing operations validate canonical relay addresses, aliases, claim tokens, and ask/message IDs before accessing the relay root; path constructors and raw persistence helpers remain internal. Core uses Node built-ins plus Koffi's prebuilt native bridge for descriptor-relative Unix syscalls; Pi and TUI remain optional peers, so core-only installations do not fetch them. The package root remains the pi extension entry point; import `/core` from plain Node services and CLIs.
21
21
 
22
+ ### Registering a non-Pi host
23
+
24
+ A host that isn't a pi session (a supervisor, a CLI daemon) can join the relay so it shows in `list`, can be messaged by name, and can hold an alias:
25
+
26
+ ```ts
27
+ import { claimAlias, heartbeat, register, unregister } from '@nicknisi/pi-relay/core';
28
+
29
+ const { addr } = register(root, { sessionId, name: 'supervisor', cwd: process.cwd() }); // pid defaults to process.pid, status to 'idle'
30
+ claimAlias(root, 'supervisor', addr, sessionId);
31
+ const timer = setInterval(() => heartbeat(root, addr), 15_000); // optional patch: { status, name }
32
+ // on shutdown:
33
+ clearInterval(timer);
34
+ unregister(root, addr);
35
+ ```
36
+
37
+ - `register` writes the same `<addr>.json` record the extension writes. The address is derived from `cwd` + `sessionId`, so re-registering keeps the address and the original `startedAt`.
38
+ - `heartbeat` refreshes `lastSeenAt` (and clears `offline`). Core runs no timers: the host must call it less than `HEARTBEAT_STALE_MS` (45 s) apart — every 15 s, like the extension — or it reads as `stalled`. It returns `null`, writing nothing, if the record is gone; it never re-creates an unregistered session.
39
+ - `unregister` deletes the record (missing is fine). Unlike the extension's shutdown, which marks the session offline so queued mail stays addressable, the address stops existing.
40
+ - An alias lives only as long as its record: `sweep` keeps aliases whose record exists and clears the rest, so an alias survives while the host keeps registered and is reclaimed by the next `sweep` after `unregister`.
41
+
22
42
  ### Durable core inbox claims
23
43
 
24
44
  A non-Pi consumer can atomically detach the current inbox with `claimInbox(root, addr)`. It receives only a stable `claimToken` and stable `fileTokens`, never filesystem paths. New deposits immediately land in a fresh inbox. `readClaimedLetter` reopens and validates a claimed letter without following links; after the consumer durably writes and `fsync`s its own journal, `ackClaimedLetter` atomically persists exact delivery proof in `<addr>.acks` before deleting the claimed file, or `requeueClaimedLetter` atomically returns it to the current inbox. `recoverInboxClaims` enumerates the same tokens after a process crash. All claim, read, ack, and requeue work is relative to pinned root/inbox/claim/ack descriptors, and empty completed claims are removed automatically. `sweep` removes proof more than seven days after acknowledgement, including for live sessions; proof never keeps an expired session's record alive. The destructive `drain` API does not acknowledge delivery.
@@ -61,6 +81,7 @@ main moved; rebase before you push.
61
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.
62
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.
63
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.
64
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.
65
86
  - **Plain text only, ≤32KB.** Send a summary and a path, not a payload.
66
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.
@@ -108,5 +129,5 @@ The directory is created `0700` and every file `0600` — other users on the mac
108
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.
109
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.
110
131
  - **Presence is heartbeat-accurate**, not instantaneous (within ~45s).
111
- - 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.
112
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
@@ -27,13 +27,16 @@ import {
27
27
  import {
28
28
  claimAlias as claimAliasInternal,
29
29
  clearAlias as clearAliasInternal,
30
+ deriveAddr,
30
31
  isValidAliasName as isValidAliasNameInternal,
31
32
  listAliases as listAliasesInternal,
32
33
  listRecords as listRecordsInternal,
33
34
  presenceOf as presenceOfInternal,
34
35
  readAlias as readAliasInternal,
35
36
  readRecord as readRecordInternal,
37
+ removeRecord,
36
38
  sweep as sweepInternal,
39
+ writeRecord,
37
40
  type AliasRecord,
38
41
  type Presence,
39
42
  type SessionRecord,
@@ -110,6 +113,14 @@ function assertLetter(letter: Letter): void {
110
113
  }
111
114
  }
112
115
 
116
+ function assertNonEmptyString(value: unknown, label: string): asserts value is string {
117
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError(`Invalid relay ${label}: ${value}`);
118
+ }
119
+
120
+ function assertStatus(status: unknown): asserts status is SessionRecord['status'] {
121
+ if (status !== 'idle' && status !== 'working') throw new TypeError(`Invalid relay session status: ${status}`);
122
+ }
123
+
113
124
  function isSafeLetter(letter: Letter): boolean {
114
125
  try {
115
126
  assertLetter(letter);
@@ -174,6 +185,70 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
174
185
  return presenceOfInternal(record, now);
175
186
  }
176
187
 
188
+ /**
189
+ * Register (or re-register) a non-Pi host as a relay session. The address is
190
+ * derived from `cwd` + `sessionId`, so re-registering keeps it, along with the
191
+ * existing `startedAt`. The host must call `heartbeat` well inside
192
+ * `HEARTBEAT_STALE_MS`.
193
+ */
194
+ export function register(
195
+ root: string,
196
+ input: { sessionId: string; name: string; cwd: string; pid?: number; status?: SessionRecord['status'] },
197
+ ): SessionRecord {
198
+ assertNonEmptyString(input.sessionId, 'session id');
199
+ assertNonEmptyString(input.name, 'session name');
200
+ assertNonEmptyString(input.cwd, 'session cwd');
201
+ const pid = input.pid ?? process.pid;
202
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new TypeError(`Invalid relay session pid: ${pid}`);
203
+ const status = input.status ?? 'idle';
204
+ assertStatus(status);
205
+ const addr = deriveAddr(input.cwd, input.sessionId);
206
+ const now = Date.now();
207
+ const record: SessionRecord = {
208
+ addr,
209
+ sessionId: input.sessionId,
210
+ name: input.name,
211
+ cwd: input.cwd,
212
+ pid,
213
+ startedAt: readRecord(root, addr)?.startedAt ?? now,
214
+ lastSeenAt: now,
215
+ status,
216
+ };
217
+ writeRecord(root, record);
218
+ return record;
219
+ }
220
+
221
+ /**
222
+ * Refresh a registered session's `lastSeenAt` (clearing `offline`), applying an
223
+ * optional status or name. Returns null, writing nothing, if no record exists.
224
+ */
225
+ export function heartbeat(
226
+ root: string,
227
+ addr: string,
228
+ patch: { status?: SessionRecord['status']; name?: string } = {},
229
+ ): SessionRecord | null {
230
+ assertAddress(addr);
231
+ if (patch.status !== undefined) assertStatus(patch.status);
232
+ if (patch.name !== undefined) assertNonEmptyString(patch.name, 'session name');
233
+ const existing = readRecord(root, addr);
234
+ if (existing === null) return null;
235
+ const { offline: _offline, ...rest } = existing;
236
+ const record: SessionRecord = {
237
+ ...rest,
238
+ name: patch.name ?? rest.name,
239
+ status: patch.status ?? rest.status,
240
+ lastSeenAt: Date.now(),
241
+ };
242
+ writeRecord(root, record);
243
+ return record;
244
+ }
245
+
246
+ /** Delete a session's record. Aliases pointing at it are cleared by the next `sweep`. */
247
+ export function unregister(root: string, addr: string): void {
248
+ assertAddress(addr);
249
+ removeRecord(root, addr);
250
+ }
251
+
177
252
  export function readAlias(root: string, name: string): AliasRecord | null {
178
253
  assertAliasName(name);
179
254
  const alias = readAliasInternal(root, name);
@@ -205,6 +280,17 @@ export function sweep(root: string, now: number = Date.now(), sessionExists?: (s
205
280
  export function deposit(root: string, toAddr: string, letter: Letter): void {
206
281
  assertAddress(toAddr);
207
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
+ }
208
294
  depositInternal(root, toAddr, letter);
209
295
  }
210
296
 
package/dist/core.d.ts CHANGED
@@ -7,6 +7,29 @@ export { HEARTBEAT_STALE_MS, SWEEP_MAIL_KEEP_MS, deriveAddr, isValidAliasName, t
7
7
  export declare function readRecord(root: string, addr: string): SessionRecord | null;
8
8
  export declare function listRecords(root: string): SessionRecord[];
9
9
  export declare function presenceOf(record: SessionRecord, now?: number): Presence;
10
+ /**
11
+ * Register (or re-register) a non-Pi host as a relay session. The address is
12
+ * derived from `cwd` + `sessionId`, so re-registering keeps it, along with the
13
+ * existing `startedAt`. The host must call `heartbeat` well inside
14
+ * `HEARTBEAT_STALE_MS`.
15
+ */
16
+ export declare function register(root: string, input: {
17
+ sessionId: string;
18
+ name: string;
19
+ cwd: string;
20
+ pid?: number;
21
+ status?: SessionRecord['status'];
22
+ }): SessionRecord;
23
+ /**
24
+ * Refresh a registered session's `lastSeenAt` (clearing `offline`), applying an
25
+ * optional status or name. Returns null, writing nothing, if no record exists.
26
+ */
27
+ export declare function heartbeat(root: string, addr: string, patch?: {
28
+ status?: SessionRecord['status'];
29
+ name?: string;
30
+ }): SessionRecord | null;
31
+ /** Delete a session's record. Aliases pointing at it are cleared by the next `sweep`. */
32
+ export declare function unregister(root: string, addr: string): void;
10
33
  export declare function readAlias(root: string, name: string): AliasRecord | null;
11
34
  export declare function listAliases(root: string): AliasRecord[];
12
35
  export declare function claimAlias(root: string, name: string, addr: string, sessionId: string): AliasRecord;
package/dist/core.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Pi-free relay registry, mailbox, and policy API for non-extension consumers. */
2
2
  import { ackClaimedLetter as ackClaimedLetterInternal, awaitReceipt as awaitReceiptInternal, claimInbox as claimInboxInternal, clearAsk as clearAskInternal, deposit as depositInternal, drain as drainInternal, pendingAsks as pendingAsksInternal, readAudit as readAuditInternal, readClaimedLetter as readClaimedLetterInternal, readIncomingAsk as readIncomingAskInternal, readOutgoingAsk as readOutgoingAskInternal, recoverInboxClaims as recoverInboxClaimsInternal, requeueClaimedLetter as requeueClaimedLetterInternal, resolveAskByRef as resolveAskByRefInternal, trackIncomingAsk as trackIncomingAskInternal, trackOutgoingAsk as trackOutgoingAskInternal, unreadCount as unreadCountInternal, watchInbox as watchInboxInternal, } from './mailbox.js';
3
- import { claimAlias as claimAliasInternal, clearAlias as clearAliasInternal, isValidAliasName as isValidAliasNameInternal, listAliases as listAliasesInternal, listRecords as listRecordsInternal, presenceOf as presenceOfInternal, readAlias as readAliasInternal, readRecord as readRecordInternal, sweep as sweepInternal, } from './registry.js';
3
+ import { claimAlias as claimAliasInternal, clearAlias as clearAliasInternal, deriveAddr, isValidAliasName as isValidAliasNameInternal, listAliases as listAliasesInternal, listRecords as listRecordsInternal, presenceOf as presenceOfInternal, readAlias as readAliasInternal, readRecord as readRecordInternal, removeRecord, sweep as sweepInternal, writeRecord, } from './registry.js';
4
4
  export { MAX_BODY_CHARS, previewBody, } from './mailbox.js';
5
5
  export { BACKLOG_CAP, DEDUPE_WINDOW_MS, OutboundPolicy, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_MS, inboundAccepts, } from './policy.js';
6
6
  export { HEARTBEAT_STALE_MS, SWEEP_MAIL_KEEP_MS, deriveAddr, isValidAliasName, } from './registry.js';
@@ -47,6 +47,14 @@ function assertLetter(letter) {
47
47
  throw new TypeError(`Invalid relay letter timestamp: ${letter.ts}`);
48
48
  }
49
49
  }
50
+ function assertNonEmptyString(value, label) {
51
+ if (typeof value !== 'string' || value.length === 0)
52
+ throw new TypeError(`Invalid relay ${label}: ${value}`);
53
+ }
54
+ function assertStatus(status) {
55
+ if (status !== 'idle' && status !== 'working')
56
+ throw new TypeError(`Invalid relay session status: ${status}`);
57
+ }
50
58
  function isSafeLetter(letter) {
51
59
  try {
52
60
  assertLetter(letter);
@@ -108,6 +116,64 @@ export function presenceOf(record, now = Date.now()) {
108
116
  assertAddress(record.addr);
109
117
  return presenceOfInternal(record, now);
110
118
  }
119
+ /**
120
+ * Register (or re-register) a non-Pi host as a relay session. The address is
121
+ * derived from `cwd` + `sessionId`, so re-registering keeps it, along with the
122
+ * existing `startedAt`. The host must call `heartbeat` well inside
123
+ * `HEARTBEAT_STALE_MS`.
124
+ */
125
+ export function register(root, input) {
126
+ assertNonEmptyString(input.sessionId, 'session id');
127
+ assertNonEmptyString(input.name, 'session name');
128
+ assertNonEmptyString(input.cwd, 'session cwd');
129
+ const pid = input.pid ?? process.pid;
130
+ if (!Number.isSafeInteger(pid) || pid <= 0)
131
+ throw new TypeError(`Invalid relay session pid: ${pid}`);
132
+ const status = input.status ?? 'idle';
133
+ assertStatus(status);
134
+ const addr = deriveAddr(input.cwd, input.sessionId);
135
+ const now = Date.now();
136
+ const record = {
137
+ addr,
138
+ sessionId: input.sessionId,
139
+ name: input.name,
140
+ cwd: input.cwd,
141
+ pid,
142
+ startedAt: readRecord(root, addr)?.startedAt ?? now,
143
+ lastSeenAt: now,
144
+ status,
145
+ };
146
+ writeRecord(root, record);
147
+ return record;
148
+ }
149
+ /**
150
+ * Refresh a registered session's `lastSeenAt` (clearing `offline`), applying an
151
+ * optional status or name. Returns null, writing nothing, if no record exists.
152
+ */
153
+ export function heartbeat(root, addr, patch = {}) {
154
+ assertAddress(addr);
155
+ if (patch.status !== undefined)
156
+ assertStatus(patch.status);
157
+ if (patch.name !== undefined)
158
+ assertNonEmptyString(patch.name, 'session name');
159
+ const existing = readRecord(root, addr);
160
+ if (existing === null)
161
+ return null;
162
+ const { offline: _offline, ...rest } = existing;
163
+ const record = {
164
+ ...rest,
165
+ name: patch.name ?? rest.name,
166
+ status: patch.status ?? rest.status,
167
+ lastSeenAt: Date.now(),
168
+ };
169
+ writeRecord(root, record);
170
+ return record;
171
+ }
172
+ /** Delete a session's record. Aliases pointing at it are cleared by the next `sweep`. */
173
+ export function unregister(root, addr) {
174
+ assertAddress(addr);
175
+ removeRecord(root, addr);
176
+ }
111
177
  export function readAlias(root, name) {
112
178
  assertAliasName(name);
113
179
  const alias = readAliasInternal(root, name);
@@ -134,6 +200,17 @@ export function sweep(root, now = Date.now(), sessionExists) {
134
200
  export function deposit(root, toAddr, letter) {
135
201
  assertAddress(toAddr);
136
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
+ }
137
214
  depositInternal(root, toAddr, letter);
138
215
  }
139
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([
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import * as core from './core.js';
6
+ const fixtures = [];
7
+ function relayRoot() {
8
+ const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'pi-relay-registration-')));
9
+ fixtures.push(dir);
10
+ return path.join(dir, 'relay');
11
+ }
12
+ const host = { sessionId: 'riker-supervisor', name: 'riker', cwd: '/work/riker' };
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ while (fixtures.length > 0)
16
+ fs.rmSync(fixtures.pop(), { recursive: true, force: true });
17
+ });
18
+ describe('/core session registration', () => {
19
+ it('registers a live session visible in listRecords', () => {
20
+ const root = relayRoot();
21
+ const record = core.register(root, host);
22
+ expect(record).toMatchObject({
23
+ addr: core.deriveAddr(host.cwd, host.sessionId),
24
+ sessionId: host.sessionId,
25
+ name: 'riker',
26
+ cwd: host.cwd,
27
+ pid: process.pid,
28
+ status: 'idle',
29
+ });
30
+ expect(record.startedAt).toBe(record.lastSeenAt);
31
+ expect(core.listRecords(root)).toEqual([record]);
32
+ expect(core.presenceOf(record)).toBe('live');
33
+ });
34
+ it('heartbeat refreshes lastSeenAt and applies the patch; unknown addresses stay unknown', () => {
35
+ vi.useFakeTimers({ now: 1_000_000 });
36
+ const root = relayRoot();
37
+ const { addr } = core.register(root, host);
38
+ vi.setSystemTime(1_015_000);
39
+ const beat = core.heartbeat(root, addr, { status: 'working', name: 'riker-2' });
40
+ expect(beat).toMatchObject({
41
+ addr,
42
+ lastSeenAt: 1_015_000,
43
+ startedAt: 1_000_000,
44
+ status: 'working',
45
+ name: 'riker-2',
46
+ });
47
+ expect(core.readRecord(root, addr)).toEqual(beat);
48
+ const unknown = core.deriveAddr('/nowhere', 'ghost');
49
+ expect(core.heartbeat(root, unknown)).toBeNull();
50
+ expect(core.readRecord(root, unknown)).toBeNull();
51
+ expect(core.listRecords(root)).toHaveLength(1);
52
+ });
53
+ it('keeps a heartbeating session alias through sweep, and sweep clears it after unregister', () => {
54
+ const root = relayRoot();
55
+ const { addr } = core.register(root, host);
56
+ core.claimAlias(root, 'riker', addr, host.sessionId);
57
+ core.heartbeat(root, addr);
58
+ core.sweep(root);
59
+ expect(core.readAlias(root, 'riker')).toMatchObject({ addr });
60
+ core.unregister(root, addr);
61
+ expect(core.listRecords(root)).toEqual([]);
62
+ core.sweep(root);
63
+ expect(core.readAlias(root, 'riker')).toBeNull();
64
+ expect(() => core.unregister(root, addr)).not.toThrow();
65
+ });
66
+ it('re-registering keeps addr and startedAt', () => {
67
+ vi.useFakeTimers({ now: 2_000_000 });
68
+ const root = relayRoot();
69
+ const first = core.register(root, host);
70
+ vi.setSystemTime(2_060_000);
71
+ const second = core.register(root, { ...host, name: 'renamed', status: 'working' });
72
+ expect(second).toMatchObject({ addr: first.addr, startedAt: 2_000_000, lastSeenAt: 2_060_000, name: 'renamed' });
73
+ expect(core.listRecords(root)).toEqual([second]);
74
+ });
75
+ it('rejects invalid input', () => {
76
+ const root = relayRoot();
77
+ const { addr } = core.register(root, host);
78
+ const calls = [
79
+ () => core.register(root, { ...host, sessionId: '' }),
80
+ () => core.register(root, { ...host, name: 42 }),
81
+ () => core.register(root, { ...host, cwd: '' }),
82
+ () => core.register(root, { ...host, pid: 0 }),
83
+ () => core.register(root, { ...host, pid: 1.5 }),
84
+ () => core.register(root, { ...host, status: 'busy' }),
85
+ () => core.heartbeat(root, '../../escaped'),
86
+ () => core.heartbeat(root, addr, { status: 'busy' }),
87
+ () => core.heartbeat(root, addr, { name: '' }),
88
+ () => core.unregister(root, '../../escaped'),
89
+ ];
90
+ for (const call of calls)
91
+ expect(call).toThrow(TypeError);
92
+ });
93
+ });
@@ -70,6 +70,8 @@ export declare function listAliases(root: string): AliasRecord[];
70
70
  export declare function claimAlias(root: string, name: string, addr: string, sessionId: string): AliasRecord;
71
71
  export declare function clearAlias(root: string, name: string): void;
72
72
  export declare function writeRecord(root: string, record: SessionRecord): void;
73
+ /** Delete `<addr>.json`. A missing record or relay root is fine. */
74
+ export declare function removeRecord(root: string, addr: string): void;
73
75
  export declare function readRecord(root: string, addr: string): SessionRecord | null;
74
76
  /** Read-only listing, oldest first. Never mutates anything. */
75
77
  export declare function listRecords(root: string): SessionRecord[];
package/dist/registry.js CHANGED
@@ -223,6 +223,19 @@ export function writeRecord(root, record) {
223
223
  relay.close();
224
224
  }
225
225
  }
226
+ /** Delete `<addr>.json`. A missing record or relay root is fine. */
227
+ export function removeRecord(root, addr) {
228
+ assertPathSegment(addr, 'relay address');
229
+ const relay = openRelayRoot(root);
230
+ if (relay === null)
231
+ return;
232
+ try {
233
+ relay.unlinkFile(`${addr}.json`);
234
+ }
235
+ finally {
236
+ relay.close();
237
+ }
238
+ }
226
239
  function readRecordFromDirectory(directory, addr) {
227
240
  const raw = directory.readFile(`${addr}.json`);
228
241
  if (raw === null)
@@ -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.4.1",
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",
package/registry.ts CHANGED
@@ -244,6 +244,18 @@ export function writeRecord(root: string, record: SessionRecord): void {
244
244
  }
245
245
  }
246
246
 
247
+ /** Delete `<addr>.json`. A missing record or relay root is fine. */
248
+ export function removeRecord(root: string, addr: string): void {
249
+ assertPathSegment(addr, 'relay address');
250
+ const relay = openRelayRoot(root);
251
+ if (relay === null) return;
252
+ try {
253
+ relay.unlinkFile(`${addr}.json`);
254
+ } finally {
255
+ relay.close();
256
+ }
257
+ }
258
+
247
259
  function readRecordFromDirectory(directory: RelayDirectoryHandle, addr: string): SessionRecord | null {
248
260
  const raw = directory.readFile(`${addr}.json`);
249
261
  if (raw === null) return null;