@nicknisi/pi-relay 0.1.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.1.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/policy.ts CHANGED
@@ -24,9 +24,11 @@ export class OutboundPolicy {
24
24
  /**
25
25
  * Gate one outbound letter. `unreadBacklog` is the target's current
26
26
  * unread count (report 0 when the target is idle — an idle agent has by
27
- * definition worked through what it was handed).
27
+ * definition worked through what it was handed). `target` scopes the
28
+ * identical-body dedupe to a single peer (loop-breaking) so a broadcast
29
+ * of one body to N peers is not deduped after the first.
28
30
  */
29
- check(body: string, unreadBacklog: number): OutboundVerdict {
31
+ check(body: string, unreadBacklog: number, target?: string): OutboundVerdict {
30
32
  if (body.length > MAX_BODY_CHARS) {
31
33
  return {
32
34
  ok: false,
@@ -40,9 +42,10 @@ export class OutboundPolicy {
40
42
  };
41
43
  }
42
44
  const now = this.now();
43
- const lastSame = this.recentBodies.get(body);
45
+ const dedupeKey = `${body}\u0000${target ?? ''}`;
46
+ const lastSame = this.recentBodies.get(dedupeKey);
44
47
  if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
45
- return { ok: false, reason: 'Identical message sent less than 10s ago — dropped to break loops.' };
48
+ return { ok: false, reason: 'Identical message to the same peer less than 10s ago — dropped to break loops.' };
46
49
  }
47
50
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
48
51
  while (this.sentAt.length > 0 && this.sentAt[0]! < windowStart) this.sentAt.shift();
@@ -56,9 +59,9 @@ export class OutboundPolicy {
56
59
  }
57
60
 
58
61
  /** Record a successful send so dedupe/rate state stays current. */
59
- recordSend(body: string): void {
62
+ recordSend(body: string, target?: string): void {
60
63
  this.sentAt.push(this.now());
61
- this.recentBodies.set(body, this.now());
64
+ this.recentBodies.set(`${body}\u0000${target ?? ''}`, this.now());
62
65
  // bound the dedupe map
63
66
  if (this.recentBodies.size > 200) {
64
67
  const cutoff = this.now() - DEDUPE_WINDOW_MS;
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,61 +48,210 @@ 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
 
70
+ // ── Claimable aliases ────────────────────────────────────────────────────
71
+ // Durable, human-readable names (@ci, @dotfiles) that point at a session
72
+ // address. Last-claim-wins: `claimAlias` overwrites unconditionally. The
73
+ // alias outlives the process and survives restart (persisted on disk, not
74
+ // runtime-only); it is swept when the owning session's record is reaped.
75
+
76
+ export interface AliasRecord {
77
+ name: string; // stored without the leading @
78
+ addr: string;
79
+ sessionId: string;
80
+ claimedAt: number;
81
+ }
82
+
83
+ export function aliasesDir(root: string): string {
84
+ return path.join(root, 'aliases');
85
+ }
86
+
87
+ export function aliasPath(root: string, name: string): string {
88
+ assertPathSegment(name, 'relay alias');
89
+ return path.join(aliasesDir(root), `${name}.json`);
90
+ }
91
+
92
+ export function writeAlias(root: string, alias: AliasRecord): void {
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
+ }
105
+ }
106
+
107
+ /** Alias names: lowercase alnum start, then alnum/_/-, 1-32 chars. */
108
+ export function isValidAliasName(name: string): boolean {
109
+ return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name);
110
+ }
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
+
120
+ export function readAlias(root: string, name: string): AliasRecord | null {
121
+ // The name can come from LLM-controlled tool input — never let it reach
122
+ // the filesystem unvalidated (e.g. '@../../../../tmp/x').
123
+ if (!isValidAliasName(name)) return null;
124
+ const relay = openRelayRoot(root);
125
+ if (relay === null) return null;
126
+ try {
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();
136
+ }
137
+ } finally {
138
+ relay.close();
139
+ }
140
+ }
141
+
142
+ export function listAliases(root: string): AliasRecord[] {
143
+ const relay = openRelayRoot(root);
144
+ if (relay === null) return [];
145
+ try {
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);
167
+ return [];
168
+ } finally {
169
+ relay.close();
170
+ }
171
+ }
172
+
173
+ /** Last-claim-wins: overwrites any prior owner. */
174
+ export function claimAlias(root: string, name: string, addr: string, sessionId: string): AliasRecord {
175
+ const alias: AliasRecord = { name, addr, sessionId, claimedAt: Date.now() };
176
+ writeAlias(root, alias);
177
+ return alias;
178
+ }
179
+
180
+ export function clearAlias(root: string, name: string): void {
181
+ if (!isValidAliasName(name)) return;
182
+ const relay = openRelayRoot(root);
183
+ if (relay === null) return;
184
+ try {
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();
197
+ }
198
+ }
199
+
65
200
  export function writeRecord(root: string, record: SessionRecord): void {
66
- ensureRoot(root);
67
- const file = recordPath(root, record.addr);
68
- const tmp = `${file}.${process.pid}.tmp`;
69
- fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
70
- 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;
71
215
  }
72
216
 
73
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;
74
221
  try {
75
- const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
76
- if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
77
- return parsed as SessionRecord;
78
- }
79
- return null;
80
- } catch {
222
+ return readRecordFromDirectory(relay, addr);
223
+ } catch (error) {
224
+ rethrowFilesystemError(error);
81
225
  return null;
226
+ } finally {
227
+ relay.close();
82
228
  }
83
229
  }
84
230
 
85
231
  /** Read-only listing, oldest first. Never mutates anything. */
86
232
  export function listRecords(root: string): SessionRecord[] {
87
- let entries: fs.Dirent[];
233
+ const relay = openRelayRoot(root);
234
+ if (relay === null) return [];
88
235
  try {
89
- entries = fs.readdirSync(root, { withFileTypes: true });
90
- } 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);
91
251
  return [];
252
+ } finally {
253
+ relay.close();
92
254
  }
93
- const out: SessionRecord[] = [];
94
- for (const entry of entries) {
95
- if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
96
- const record = readRecord(root, entry.name.slice(0, -'.json'.length));
97
- if (record) out.push(record);
98
- }
99
- return out.sort((a, b) => a.startedAt - b.startedAt);
100
255
  }
101
256
 
102
257
  function pidAlive(pid: number): boolean {
@@ -116,22 +271,82 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
116
271
  return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
117
272
  }
118
273
 
119
- function dirHasJson(dir: string): boolean {
274
+ function dirHasJson(root: string, name: string): boolean {
275
+ const relay = openRelayRoot(root);
276
+ if (relay === null) return false;
120
277
  try {
121
- return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
122
- } 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);
123
291
  return false;
292
+ } finally {
293
+ relay.close();
124
294
  }
125
295
  }
126
296
 
127
- function rmrf(target: string): void {
297
+ function claimsHaveJson(root: string, addr: string): boolean {
298
+ const relay = openRelayRoot(root);
299
+ if (relay === null) return false;
128
300
  try {
129
- fs.rmSync(target, { recursive: true, force: true });
130
- } catch {
131
- // 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();
132
331
  }
133
332
  }
134
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();
346
+ }
347
+ directory.removeDirectory(claimsName);
348
+ }
349
+
135
350
  /**
136
351
  * Reclaim dead sessions' files. Rules (mail outranks tidiness):
137
352
  * - a running session is never touched;
@@ -148,16 +363,34 @@ function rmrf(target: string): void {
148
363
  export function sweep(root: string, now: number = Date.now(), sessionExists?: (sessionId: string) => boolean): void {
149
364
  for (const record of listRecords(root)) {
150
365
  if (presenceOf(record, now) !== 'offline') continue;
151
- 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);
152
370
  const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
153
371
  if (hasMail && !expired) continue;
154
372
  if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
155
- rmrf(inboxDir(root, record.addr));
156
- rmrf(asksDir(root, record.addr));
157
- try {
158
- fs.unlinkSync(recordPath(root, record.addr));
159
- } catch {
160
- // 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
+ }
161
388
  }
162
389
  }
390
+ // Aliases are swept when the owning session dies: an alias whose record
391
+ // was just reaped (or never existed) is reclaimed. A resumable-but-offline
392
+ // session keeps its record, so it keeps its alias too.
393
+ for (const alias of listAliases(root)) {
394
+ if (readRecord(root, alias.addr) === null) clearAlias(root, alias.name);
395
+ }
163
396
  }