@nicknisi/pi-relay 0.4.0 → 0.5.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.
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);
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);
package/dist/index.js CHANGED
@@ -509,6 +509,7 @@ export default function relay(pi) {
509
509
  pi.registerTool({
510
510
  name: 'relay',
511
511
  label: 'Relay',
512
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
512
513
  description: 'Message other pi sessions on this machine. list/list-cwd return bounded pages of sessions, online first (offline sessions still receive mail); filter with presence or exact sessionIds. Target a full sessionId, address, name, or @alias. send returns a message id and delivered/queued/uncertain receipt. Reply to received messages or pending asks with explicit replyTo. ask waits for a correlated reply, default 120s. Also supports cancel, pending, status, claim, watch, and send broadcasts to "*" or "cwd". Plain text only, ≤32KB: send summaries and paths.',
513
514
  promptSnippet: 'Message other pi sessions on this machine',
514
515
  promptGuidelines: [
@@ -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)
package/index.ts CHANGED
@@ -567,6 +567,7 @@ export default function relay(pi: ExtensionAPI) {
567
567
  pi.registerTool({
568
568
  name: 'relay',
569
569
  label: 'Relay',
570
+ annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
570
571
  description:
571
572
  'Message other pi sessions on this machine. list/list-cwd return bounded pages of sessions, online first (offline sessions still receive mail); filter with presence or exact sessionIds. Target a full sessionId, address, name, or @alias. send returns a message id and delivered/queued/uncertain receipt. Reply to received messages or pending asks with explicit replyTo. ask waits for a correlated reply, default 120s. Also supports cancel, pending, status, claim, watch, and send broadcasts to "*" or "cwd". Plain text only, ≤32KB: send summaries and paths.',
572
573
  promptSnippet: 'Message other pi sessions on this machine',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-relay",
3
- "version": "0.4.0",
3
+ "version": "0.5.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;