@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 +22 -1
- package/core.ts +86 -0
- package/dist/core.d.ts +23 -0
- package/dist/core.js +78 -1
- 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/registration.test.d.ts +1 -0
- package/dist/registration.test.js +93 -0
- package/dist/registry.d.ts +2 -0
- package/dist/registry.js +13 -0
- package/dist/relay.test.js +20 -0
- package/index.ts +17 -0
- package/mailbox.ts +13 -0
- package/package.json +1 -1
- package/registry.ts +12 -0
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');
|
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([
|
|
@@ -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
|
+
});
|
package/dist/registry.d.ts
CHANGED
|
@@ -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/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",
|
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;
|