@nicknisi/pi-relay 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nicknisi/pi-relay",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -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
- fs.mkdirSync(root, { recursive: true, mode: 0o700 });
46
- try {
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
- const file = aliasPath(root, alias.name);
88
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
89
- const tmp = `${file}.${process.pid}.tmp`;
90
- fs.writeFileSync(tmp, JSON.stringify(alias, null, 2), { mode: 0o600 });
91
- fs.renameSync(tmp, file);
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 parsed = JSON.parse(fs.readFileSync(aliasPath(root, name), 'utf8'));
105
- if (parsed && typeof parsed.name === 'string' && typeof parsed.addr === 'string') {
106
- return parsed as AliasRecord;
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
- return null;
109
- } catch {
110
- return null;
137
+ } finally {
138
+ relay.close();
111
139
  }
112
140
  }
113
141
 
114
142
  export function listAliases(root: string): AliasRecord[] {
115
- let entries: fs.Dirent[];
143
+ const relay = openRelayRoot(root);
144
+ if (relay === null) return [];
116
145
  try {
117
- entries = fs.readdirSync(aliasesDir(root), { withFileTypes: true });
118
- } catch {
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
- fs.unlinkSync(aliasPath(root, name));
140
- } catch {
141
- // already gone
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
- ensureRoot(root);
147
- const file = recordPath(root, record.addr);
148
- const tmp = `${file}.${process.pid}.tmp`;
149
- fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
150
- fs.renameSync(tmp, file);
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
- const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
156
- if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
157
- return parsed as SessionRecord;
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
- let entries: fs.Dirent[];
233
+ const relay = openRelayRoot(root);
234
+ if (relay === null) return [];
168
235
  try {
169
- entries = fs.readdirSync(root, { withFileTypes: true });
170
- } catch {
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(dir: string): boolean {
274
+ function dirHasJson(root: string, name: string): boolean {
275
+ const relay = openRelayRoot(root);
276
+ if (relay === null) return false;
200
277
  try {
201
- return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
202
- } catch {
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 rmrf(target: string): void {
297
+ function claimsHaveJson(root: string, addr: string): boolean {
298
+ const relay = openRelayRoot(root);
299
+ if (relay === null) return false;
208
300
  try {
209
- fs.rmSync(target, { recursive: true, force: true });
210
- } catch {
211
- // sweep is best-effort
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 = dirHasJson(inboxDir(root, record.addr)) || dirHasJson(asksDir(root, record.addr));
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
- rmrf(inboxDir(root, record.addr));
236
- rmrf(asksDir(root, record.addr));
237
- try {
238
- fs.unlinkSync(recordPath(root, record.addr));
239
- } catch {
240
- // already gone
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