@nicknisi/pi-relay 0.2.0 → 0.3.1
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 +17 -3
- package/core.ts +314 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +223 -0
- package/dist/core.test.d.ts +1 -0
- package/dist/core.test.js +358 -0
- package/dist/filesystem.d.ts +42 -0
- package/dist/filesystem.js +614 -0
- package/dist/index.js +105 -26
- package/dist/mailbox.d.ts +30 -8
- package/dist/mailbox.js +615 -126
- package/dist/registry.js +254 -70
- package/dist/relay.test.js +204 -2
- package/filesystem.ts +635 -0
- package/index.ts +101 -25
- package/mailbox.ts +557 -122
- package/package.json +19 -1
- package/registry.ts +213 -66
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicknisi/pi-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"index.ts",
|
|
20
|
+
"core.ts",
|
|
21
|
+
"filesystem.ts",
|
|
20
22
|
"registry.ts",
|
|
21
23
|
"mailbox.ts",
|
|
22
24
|
"policy.ts",
|
|
@@ -27,18 +29,34 @@
|
|
|
27
29
|
".": {
|
|
28
30
|
"types": "./dist/index.d.ts",
|
|
29
31
|
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./core": {
|
|
34
|
+
"types": "./dist/core.d.ts",
|
|
35
|
+
"default": "./dist/core.js"
|
|
30
36
|
}
|
|
31
37
|
},
|
|
32
38
|
"dependencies": {
|
|
39
|
+
"koffi": "^3.1.4",
|
|
33
40
|
"typebox": "^1.1.0"
|
|
34
41
|
},
|
|
35
42
|
"peerDependencies": {
|
|
36
43
|
"@earendil-works/pi-coding-agent": "*",
|
|
37
44
|
"@earendil-works/pi-tui": "*"
|
|
38
45
|
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": {
|
|
48
|
+
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"@earendil-works/pi-tui": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
39
54
|
"pi": {
|
|
40
55
|
"extensions": [
|
|
41
56
|
"./index.ts"
|
|
42
57
|
]
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "node ../../scripts/build.ts"
|
|
43
61
|
}
|
|
44
62
|
}
|
package/registry.ts
CHANGED
|
@@ -16,8 +16,14 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import * as crypto from 'node:crypto';
|
|
19
|
-
import * as fs from 'node:fs';
|
|
20
19
|
import * as path from 'node:path';
|
|
20
|
+
import {
|
|
21
|
+
assertPathSegment,
|
|
22
|
+
openRelayRoot,
|
|
23
|
+
rethrowFilesystemError,
|
|
24
|
+
RelayDirectoryHandle,
|
|
25
|
+
RelayFilesystemError,
|
|
26
|
+
} from './filesystem.js';
|
|
21
27
|
|
|
22
28
|
export interface SessionRecord {
|
|
23
29
|
addr: string;
|
|
@@ -42,23 +48,22 @@ export function deriveAddr(cwd: string, sessionId: string): string {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export function ensureRoot(root: string): void {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fs.chmodSync(root, 0o700);
|
|
48
|
-
} catch {
|
|
49
|
-
// best-effort on filesystems that support modes
|
|
50
|
-
}
|
|
51
|
+
const directory = openRelayRoot(root, true)!;
|
|
52
|
+
directory.close();
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
export function recordPath(root: string, addr: string): string {
|
|
56
|
+
assertPathSegment(addr, 'relay address');
|
|
54
57
|
return path.join(root, `${addr}.json`);
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export function inboxDir(root: string, addr: string): string {
|
|
61
|
+
assertPathSegment(addr, 'relay address');
|
|
58
62
|
return path.join(root, `${addr}.inbox`);
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
export function asksDir(root: string, addr: string): string {
|
|
66
|
+
assertPathSegment(addr, 'relay address');
|
|
62
67
|
return path.join(root, `${addr}.asks`);
|
|
63
68
|
}
|
|
64
69
|
|
|
@@ -80,15 +85,23 @@ export function aliasesDir(root: string): string {
|
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
export function aliasPath(root: string, name: string): string {
|
|
88
|
+
assertPathSegment(name, 'relay alias');
|
|
83
89
|
return path.join(aliasesDir(root), `${name}.json`);
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export function writeAlias(root: string, alias: AliasRecord): void {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
if (!isValidAliasName(alias.name)) throw new TypeError(`Invalid relay alias: ${alias.name}`);
|
|
94
|
+
const relay = openRelayRoot(root, true)!;
|
|
95
|
+
try {
|
|
96
|
+
const aliases = relay.openDirectory('aliases', true)!;
|
|
97
|
+
try {
|
|
98
|
+
aliases.writeFileAtomic(`${alias.name}.json`, JSON.stringify(alias, null, 2));
|
|
99
|
+
} finally {
|
|
100
|
+
aliases.close();
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
relay.close();
|
|
104
|
+
}
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
/** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
|
|
@@ -96,35 +109,65 @@ export function isValidAliasName(name: string): boolean {
|
|
|
96
109
|
return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
function readAliasFromDirectory(directory: RelayDirectoryHandle, name: string): AliasRecord | null {
|
|
113
|
+
const raw = directory.readFile(`${name}.json`);
|
|
114
|
+
if (raw === null) return null;
|
|
115
|
+
const parsed = JSON.parse(raw);
|
|
116
|
+
if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string') return parsed as AliasRecord;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
export function readAlias(root: string, name: string): AliasRecord | null {
|
|
100
121
|
// The name can come from LLM-controlled tool input — never let it reach
|
|
101
122
|
// the filesystem unvalidated (e.g. '@../../../../tmp/x').
|
|
102
123
|
if (!isValidAliasName(name)) return null;
|
|
124
|
+
const relay = openRelayRoot(root);
|
|
125
|
+
if (relay === null) return null;
|
|
103
126
|
try {
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
127
|
+
const aliases = relay.openDirectory('aliases');
|
|
128
|
+
if (aliases === null) return null;
|
|
129
|
+
try {
|
|
130
|
+
return readAliasFromDirectory(aliases, name);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
rethrowFilesystemError(error);
|
|
133
|
+
return null;
|
|
134
|
+
} finally {
|
|
135
|
+
aliases.close();
|
|
107
136
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return null;
|
|
137
|
+
} finally {
|
|
138
|
+
relay.close();
|
|
111
139
|
}
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
export function listAliases(root: string): AliasRecord[] {
|
|
115
|
-
|
|
143
|
+
const relay = openRelayRoot(root);
|
|
144
|
+
if (relay === null) return [];
|
|
116
145
|
try {
|
|
117
|
-
|
|
118
|
-
|
|
146
|
+
const aliases = relay.openDirectory('aliases');
|
|
147
|
+
if (aliases === null) return [];
|
|
148
|
+
try {
|
|
149
|
+
const out: AliasRecord[] = [];
|
|
150
|
+
for (const entry of aliases.readDirectory()) {
|
|
151
|
+
if (!entry.name.endsWith('.json')) continue;
|
|
152
|
+
if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay alias: ${entry.name}`);
|
|
153
|
+
if (!entry.isFile()) continue;
|
|
154
|
+
try {
|
|
155
|
+
const alias = readAliasFromDirectory(aliases, entry.name.slice(0, -'.json'.length));
|
|
156
|
+
if (alias) out.push(alias);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
rethrowFilesystemError(error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return out.sort((a, b) => a.claimedAt - b.claimedAt);
|
|
162
|
+
} finally {
|
|
163
|
+
aliases.close();
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
rethrowFilesystemError(error);
|
|
119
167
|
return [];
|
|
168
|
+
} finally {
|
|
169
|
+
relay.close();
|
|
120
170
|
}
|
|
121
|
-
const out: AliasRecord[] = [];
|
|
122
|
-
for (const entry of entries) {
|
|
123
|
-
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
124
|
-
const alias = readAlias(root, entry.name.slice(0, -'.json'.length));
|
|
125
|
-
if (alias) out.push(alias);
|
|
126
|
-
}
|
|
127
|
-
return out.sort((a, b) => a.claimedAt - b.claimedAt);
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
/** Last-claim-wins: overwrites any prior owner. */
|
|
@@ -135,48 +178,80 @@ export function claimAlias(root: string, name: string, addr: string, sessionId:
|
|
|
135
178
|
}
|
|
136
179
|
|
|
137
180
|
export function clearAlias(root: string, name: string): void {
|
|
181
|
+
if (!isValidAliasName(name)) return;
|
|
182
|
+
const relay = openRelayRoot(root);
|
|
183
|
+
if (relay === null) return;
|
|
138
184
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
185
|
+
const aliases = relay.openDirectory('aliases');
|
|
186
|
+
if (aliases === null) return;
|
|
187
|
+
try {
|
|
188
|
+
aliases.unlinkFile(`${name}.json`);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
rethrowFilesystemError(error);
|
|
191
|
+
// deletion remains best-effort for ordinary I/O failures
|
|
192
|
+
} finally {
|
|
193
|
+
aliases.close();
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
relay.close();
|
|
142
197
|
}
|
|
143
198
|
}
|
|
144
199
|
|
|
145
200
|
export function writeRecord(root: string, record: SessionRecord): void {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
201
|
+
const relay = openRelayRoot(root, true)!;
|
|
202
|
+
try {
|
|
203
|
+
relay.writeFileAtomic(`${record.addr}.json`, JSON.stringify(record, null, 2));
|
|
204
|
+
} finally {
|
|
205
|
+
relay.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readRecordFromDirectory(directory: RelayDirectoryHandle, addr: string): SessionRecord | null {
|
|
210
|
+
const raw = directory.readFile(`${addr}.json`);
|
|
211
|
+
if (raw === null) return null;
|
|
212
|
+
const parsed = JSON.parse(raw);
|
|
213
|
+
if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') return parsed as SessionRecord;
|
|
214
|
+
return null;
|
|
151
215
|
}
|
|
152
216
|
|
|
153
217
|
export function readRecord(root: string, addr: string): SessionRecord | null {
|
|
218
|
+
assertPathSegment(addr, 'relay address');
|
|
219
|
+
const relay = openRelayRoot(root);
|
|
220
|
+
if (relay === null) return null;
|
|
154
221
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
} catch {
|
|
222
|
+
return readRecordFromDirectory(relay, addr);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
rethrowFilesystemError(error);
|
|
161
225
|
return null;
|
|
226
|
+
} finally {
|
|
227
|
+
relay.close();
|
|
162
228
|
}
|
|
163
229
|
}
|
|
164
230
|
|
|
165
231
|
/** Read-only listing, oldest first. Never mutates anything. */
|
|
166
232
|
export function listRecords(root: string): SessionRecord[] {
|
|
167
|
-
|
|
233
|
+
const relay = openRelayRoot(root);
|
|
234
|
+
if (relay === null) return [];
|
|
168
235
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
236
|
+
const out: SessionRecord[] = [];
|
|
237
|
+
for (const entry of relay.readDirectory()) {
|
|
238
|
+
if (!entry.name.endsWith('.json')) continue;
|
|
239
|
+
if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay record: ${entry.name}`);
|
|
240
|
+
if (!entry.isFile()) continue;
|
|
241
|
+
try {
|
|
242
|
+
const record = readRecordFromDirectory(relay, entry.name.slice(0, -'.json'.length));
|
|
243
|
+
if (record) out.push(record);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
rethrowFilesystemError(error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out.sort((a, b) => a.startedAt - b.startedAt);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
rethrowFilesystemError(error);
|
|
171
251
|
return [];
|
|
252
|
+
} finally {
|
|
253
|
+
relay.close();
|
|
172
254
|
}
|
|
173
|
-
const out: SessionRecord[] = [];
|
|
174
|
-
for (const entry of entries) {
|
|
175
|
-
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
176
|
-
const record = readRecord(root, entry.name.slice(0, -'.json'.length));
|
|
177
|
-
if (record) out.push(record);
|
|
178
|
-
}
|
|
179
|
-
return out.sort((a, b) => a.startedAt - b.startedAt);
|
|
180
255
|
}
|
|
181
256
|
|
|
182
257
|
function pidAlive(pid: number): boolean {
|
|
@@ -196,20 +271,80 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
|
|
|
196
271
|
return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
|
|
197
272
|
}
|
|
198
273
|
|
|
199
|
-
function dirHasJson(
|
|
274
|
+
function dirHasJson(root: string, name: string): boolean {
|
|
275
|
+
const relay = openRelayRoot(root);
|
|
276
|
+
if (relay === null) return false;
|
|
200
277
|
try {
|
|
201
|
-
|
|
202
|
-
|
|
278
|
+
const directory = relay.openDirectory(name);
|
|
279
|
+
if (directory === null) return false;
|
|
280
|
+
try {
|
|
281
|
+
return directory.readDirectory().some((entry) => {
|
|
282
|
+
if (!entry.name.endsWith('.json')) return false;
|
|
283
|
+
if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay entry: ${entry.name}`);
|
|
284
|
+
return entry.isFile();
|
|
285
|
+
});
|
|
286
|
+
} finally {
|
|
287
|
+
directory.close();
|
|
288
|
+
}
|
|
289
|
+
} catch (error) {
|
|
290
|
+
rethrowFilesystemError(error);
|
|
203
291
|
return false;
|
|
292
|
+
} finally {
|
|
293
|
+
relay.close();
|
|
204
294
|
}
|
|
205
295
|
}
|
|
206
296
|
|
|
207
|
-
function
|
|
297
|
+
function claimsHaveJson(root: string, addr: string): boolean {
|
|
298
|
+
const relay = openRelayRoot(root);
|
|
299
|
+
if (relay === null) return false;
|
|
208
300
|
try {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
301
|
+
const claims = relay.openDirectory(`${addr}.claims`);
|
|
302
|
+
if (claims === null) return false;
|
|
303
|
+
try {
|
|
304
|
+
for (const entry of claims.readDirectory()) {
|
|
305
|
+
if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
|
|
306
|
+
if (!entry.isDirectory()) continue;
|
|
307
|
+
const claim = claims.openDirectory(entry.name);
|
|
308
|
+
if (claim === null) continue;
|
|
309
|
+
try {
|
|
310
|
+
if (
|
|
311
|
+
claim.readDirectory().some((file) => {
|
|
312
|
+
if (!file.name.endsWith('.json')) return false;
|
|
313
|
+
if (file.isSymbolicLink()) {
|
|
314
|
+
throw new RelayFilesystemError(`Refusing symlinked claimed letter: ${file.name}`);
|
|
315
|
+
}
|
|
316
|
+
return file.isFile();
|
|
317
|
+
})
|
|
318
|
+
) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
claim.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
} finally {
|
|
327
|
+
claims.close();
|
|
328
|
+
}
|
|
329
|
+
} finally {
|
|
330
|
+
relay.close();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function removeClaims(directory: RelayDirectoryHandle, addr: string): void {
|
|
335
|
+
const claimsName = `${addr}.claims`;
|
|
336
|
+
const claims = directory.openDirectory(claimsName);
|
|
337
|
+
if (claims === null) return;
|
|
338
|
+
try {
|
|
339
|
+
for (const entry of claims.readDirectory()) {
|
|
340
|
+
if (entry.isSymbolicLink()) throw new RelayFilesystemError(`Refusing symlinked relay claim: ${entry.name}`);
|
|
341
|
+
if (!entry.isDirectory()) continue;
|
|
342
|
+
claims.removeDirectory(entry.name);
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
claims.close();
|
|
212
346
|
}
|
|
347
|
+
directory.removeDirectory(claimsName);
|
|
213
348
|
}
|
|
214
349
|
|
|
215
350
|
/**
|
|
@@ -228,16 +363,28 @@ function rmrf(target: string): void {
|
|
|
228
363
|
export function sweep(root: string, now: number = Date.now(), sessionExists?: (sessionId: string) => boolean): void {
|
|
229
364
|
for (const record of listRecords(root)) {
|
|
230
365
|
if (presenceOf(record, now) !== 'offline') continue;
|
|
231
|
-
const hasMail =
|
|
366
|
+
const hasMail =
|
|
367
|
+
dirHasJson(root, `${record.addr}.inbox`) ||
|
|
368
|
+
dirHasJson(root, `${record.addr}.asks`) ||
|
|
369
|
+
claimsHaveJson(root, record.addr);
|
|
232
370
|
const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
|
|
233
371
|
if (hasMail && !expired) continue;
|
|
234
372
|
if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
373
|
+
const relay = openRelayRoot(root);
|
|
374
|
+
if (relay !== null) {
|
|
375
|
+
try {
|
|
376
|
+
relay.removeDirectory(`${record.addr}.inbox`);
|
|
377
|
+
relay.removeDirectory(`${record.addr}.asks`);
|
|
378
|
+
removeClaims(relay, record.addr);
|
|
379
|
+
try {
|
|
380
|
+
relay.unlinkFile(`${record.addr}.json`);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
rethrowFilesystemError(error);
|
|
383
|
+
// deletion remains best-effort for ordinary I/O failures
|
|
384
|
+
}
|
|
385
|
+
} finally {
|
|
386
|
+
relay.close();
|
|
387
|
+
}
|
|
241
388
|
}
|
|
242
389
|
}
|
|
243
390
|
// Aliases are swept when the owning session dies: an alias whose record
|