@nicknisi/pi-relay 0.0.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.
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Letter transport for the relay mailbox. Pi-free.
3
+ *
4
+ * Guarantees (each pinned by a test):
5
+ * - A reader never sees half a letter: writers rename into place; readers
6
+ * only look at `*.json`.
7
+ * - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
8
+ * the caller handles it.
9
+ * - A corrupt letter is discarded on read, so it cannot poison every drain.
10
+ * - Consumption is the receipt: the sender learns "delivered" only when the
11
+ * letter actually disappeared from the target's inbox.
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { asksDir, inboxDir } from './registry.js';
16
+ export const MAX_BODY_CHARS = 32 * 1024;
17
+ function letterFileName(letter) {
18
+ return `${letter.ts}-${letter.id.slice(0, 6)}.json`;
19
+ }
20
+ /** Atomic deposit: write to a tmp sibling, then rename into place. */
21
+ export function deposit(root, toAddr, letter) {
22
+ const dir = inboxDir(root, toAddr);
23
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
24
+ const name = letterFileName(letter);
25
+ const tmp = path.join(dir, `${name}.${process.pid}.tmp`);
26
+ fs.writeFileSync(tmp, JSON.stringify(letter), { mode: 0o600 });
27
+ fs.renameSync(tmp, path.join(dir, name));
28
+ }
29
+ /**
30
+ * Take every letter in the inbox, oldest first. Each letter is unlinked as
31
+ * it is read — before parsing — so it can never be delivered twice. The
32
+ * tradeoff: a drained letter that is never delivered would be lost, so the
33
+ * caller must re-deposit any letter it fails to deliver (checkInbox does).
34
+ */
35
+ export function drain(root, addr) {
36
+ const dir = inboxDir(root, addr);
37
+ let names;
38
+ try {
39
+ names = fs
40
+ .readdirSync(dir)
41
+ .filter((f) => f.endsWith('.json'))
42
+ .sort();
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ const out = [];
48
+ for (const name of names) {
49
+ const file = path.join(dir, name);
50
+ let raw;
51
+ try {
52
+ raw = fs.readFileSync(file, 'utf8');
53
+ }
54
+ catch {
55
+ continue;
56
+ }
57
+ try {
58
+ fs.unlinkSync(file);
59
+ }
60
+ catch {
61
+ continue; // another drainer took it
62
+ }
63
+ try {
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed && typeof parsed.id === 'string' && typeof parsed.body === 'string') {
66
+ out.push(parsed);
67
+ }
68
+ }
69
+ catch {
70
+ // corrupt letter — discarded, cannot fail future drains
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ export function unreadCount(root, addr) {
76
+ try {
77
+ return fs.readdirSync(inboxDir(root, addr)).filter((f) => f.endsWith('.json')).length;
78
+ }
79
+ catch {
80
+ return 0;
81
+ }
82
+ }
83
+ /**
84
+ * Watch an inbox for new mail. fs.watch with a 3s poll fallback (watch can
85
+ * miss events). The callback is expected to drain; it must tolerate empty.
86
+ * Returns an unwatch function. Timer/watcher are unref'd so they never keep
87
+ * a process alive.
88
+ */
89
+ export function watchInbox(root, addr, onMail) {
90
+ const dir = inboxDir(root, addr);
91
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+ let watcher;
93
+ try {
94
+ watcher = fs.watch(dir, () => onMail());
95
+ watcher.unref();
96
+ }
97
+ catch {
98
+ // poll-only fallback
99
+ }
100
+ const poll = setInterval(onMail, 3000);
101
+ poll.unref();
102
+ return () => {
103
+ watcher?.close();
104
+ clearInterval(poll);
105
+ };
106
+ }
107
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
108
+ /**
109
+ * Consumption receipt: after depositing to a LIVE target, wait briefly for
110
+ * the letter to vanish. 'delivered' means the receiver's drainer took it —
111
+ * stronger than any transport ack.
112
+ */
113
+ export async function awaitReceipt(root, toAddr, letter, timeoutMs = 1500) {
114
+ const file = path.join(inboxDir(root, toAddr), letterFileName(letter));
115
+ const deadline = Date.now() + timeoutMs;
116
+ while (Date.now() < deadline) {
117
+ if (!fs.existsSync(file))
118
+ return 'delivered';
119
+ await sleep(100);
120
+ }
121
+ return fs.existsSync(file) ? 'queued' : 'delivered';
122
+ }
123
+ function asksPath(root, addr, askId) {
124
+ return path.join(asksDir(root, addr), `${askId}.json`);
125
+ }
126
+ function outAskPath(root, addr, askId) {
127
+ return path.join(asksDir(root, addr), `out-${askId}.json`);
128
+ }
129
+ function writeJson0600(file, value) {
130
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
131
+ const tmp = `${file}.${process.pid}.tmp`;
132
+ fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
133
+ fs.renameSync(tmp, file);
134
+ }
135
+ export function trackIncomingAsk(root, addr, letter) {
136
+ writeJson0600(asksPath(root, addr, letter.id), letter);
137
+ }
138
+ export function trackOutgoingAsk(root, addr, out) {
139
+ writeJson0600(outAskPath(root, addr, out.askId), out);
140
+ }
141
+ export function readIncomingAsk(root, addr, askId) {
142
+ try {
143
+ const parsed = JSON.parse(fs.readFileSync(asksPath(root, addr, askId), 'utf8'));
144
+ return parsed && typeof parsed.id === 'string' ? parsed : null;
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ }
150
+ export function readOutgoingAsk(root, addr, askId) {
151
+ try {
152
+ const parsed = JSON.parse(fs.readFileSync(outAskPath(root, addr, askId), 'utf8'));
153
+ return parsed && typeof parsed.toAddr === 'string' ? parsed : null;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ /** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
160
+ export function clearAsk(root, addr, askId) {
161
+ for (const file of [asksPath(root, addr, askId), outAskPath(root, addr, askId)]) {
162
+ try {
163
+ fs.unlinkSync(file);
164
+ }
165
+ catch {
166
+ // already gone
167
+ }
168
+ }
169
+ }
170
+ /** Asks we have received and not yet answered, oldest first. */
171
+ export function pendingAsks(root, addr) {
172
+ let names;
173
+ try {
174
+ names = fs
175
+ .readdirSync(asksDir(root, addr))
176
+ .filter((f) => f.endsWith('.json') && !f.startsWith('out-'))
177
+ .sort();
178
+ }
179
+ catch {
180
+ return [];
181
+ }
182
+ const out = [];
183
+ for (const name of names) {
184
+ try {
185
+ const parsed = JSON.parse(fs.readFileSync(path.join(asksDir(root, addr), name), 'utf8'));
186
+ if (parsed && typeof parsed.id === 'string')
187
+ out.push(parsed);
188
+ }
189
+ catch {
190
+ // skip corrupt entry
191
+ }
192
+ }
193
+ return out;
194
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Loop-breaking and inbound policy. Pi-free.
3
+ *
4
+ * A loop between two agents must terminate independently of what either
5
+ * model decides: repeats, rate, and backlog are all capped here, at the
6
+ * transport, not in the prompts.
7
+ */
8
+ export declare const DEDUPE_WINDOW_MS = 10000;
9
+ export declare const RATE_LIMIT_MAX = 8;
10
+ export declare const RATE_LIMIT_WINDOW_MS = 30000;
11
+ export declare const BACKLOG_CAP = 50;
12
+ export type OutboundVerdict = {
13
+ ok: true;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
18
+ export declare class OutboundPolicy {
19
+ private readonly now;
20
+ private readonly sentAt;
21
+ private readonly recentBodies;
22
+ constructor(now?: () => number);
23
+ /**
24
+ * Gate one outbound letter. `unreadBacklog` is the target's current
25
+ * unread count (report 0 when the target is idle — an idle agent has by
26
+ * definition worked through what it was handed).
27
+ */
28
+ check(body: string, unreadBacklog: number): OutboundVerdict;
29
+ /** Record a successful send so dedupe/rate state stays current. */
30
+ recordSend(body: string): void;
31
+ }
32
+ /** Inbound guard: PI_RELAY_INBOUND=refuse drops all peer mail. */
33
+ export declare function inboundAccepts(env?: string | undefined): boolean;
package/dist/policy.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Loop-breaking and inbound policy. Pi-free.
3
+ *
4
+ * A loop between two agents must terminate independently of what either
5
+ * model decides: repeats, rate, and backlog are all capped here, at the
6
+ * transport, not in the prompts.
7
+ */
8
+ import { MAX_BODY_CHARS } from './mailbox.js';
9
+ export const DEDUPE_WINDOW_MS = 10_000;
10
+ export const RATE_LIMIT_MAX = 8;
11
+ export const RATE_LIMIT_WINDOW_MS = 30_000;
12
+ export const BACKLOG_CAP = 50;
13
+ export class OutboundPolicy {
14
+ now;
15
+ sentAt = [];
16
+ recentBodies = new Map();
17
+ constructor(now = Date.now) {
18
+ this.now = now;
19
+ }
20
+ /**
21
+ * Gate one outbound letter. `unreadBacklog` is the target's current
22
+ * unread count (report 0 when the target is idle — an idle agent has by
23
+ * definition worked through what it was handed).
24
+ */
25
+ check(body, unreadBacklog) {
26
+ if (body.length > MAX_BODY_CHARS) {
27
+ return {
28
+ ok: false,
29
+ reason: `Message is ${body.length} chars; cap is ${MAX_BODY_CHARS}. Send a summary and a path, not a payload.`,
30
+ };
31
+ }
32
+ if (unreadBacklog >= BACKLOG_CAP) {
33
+ return {
34
+ ok: false,
35
+ reason: `Peer has ${unreadBacklog} unread messages (cap ${BACKLOG_CAP}); wait for it to drain.`,
36
+ };
37
+ }
38
+ const now = this.now();
39
+ const lastSame = this.recentBodies.get(body);
40
+ if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
41
+ return { ok: false, reason: 'Identical message sent less than 10s ago — dropped to break loops.' };
42
+ }
43
+ const windowStart = now - RATE_LIMIT_WINDOW_MS;
44
+ while (this.sentAt.length > 0 && this.sentAt[0] < windowStart)
45
+ this.sentAt.shift();
46
+ if (this.sentAt.length >= RATE_LIMIT_MAX) {
47
+ return {
48
+ ok: false,
49
+ reason: `Rate limited: ${RATE_LIMIT_MAX} messages per ${RATE_LIMIT_WINDOW_MS / 1000}s per session.`,
50
+ };
51
+ }
52
+ return { ok: true };
53
+ }
54
+ /** Record a successful send so dedupe/rate state stays current. */
55
+ recordSend(body) {
56
+ this.sentAt.push(this.now());
57
+ this.recentBodies.set(body, this.now());
58
+ // bound the dedupe map
59
+ if (this.recentBodies.size > 200) {
60
+ const cutoff = this.now() - DEDUPE_WINDOW_MS;
61
+ for (const [key, ts] of this.recentBodies) {
62
+ if (ts < cutoff)
63
+ this.recentBodies.delete(key);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ /** Inbound guard: PI_RELAY_INBOUND=refuse drops all peer mail. */
69
+ export function inboundAccepts(env = process.env.PI_RELAY_INBOUND) {
70
+ return env !== 'refuse';
71
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Session registry for the relay mailbox: who is around, where, and
3
+ * whether they're reachable. Pi-free — importable from tests without pi's
4
+ * loader.
5
+ *
6
+ * Design (from pi-peer's ARCHITECTURE.md):
7
+ * - An address belongs to a CONVERSATION, not a process: hash of cwd + pi
8
+ * session id, so a resumed session (`pi -c`) answers to the same address
9
+ * and two sessions on one directory never share an inbox.
10
+ * - A record outlives the process that wrote it — that's what makes a
11
+ * session addressable while it's down (mail waits on disk).
12
+ * - Presence is a pid PLUS a heartbeat: pid alone can't tell wedged from
13
+ * healthy (and pids get reused); heartbeat alone can't tell crash from
14
+ * pause.
15
+ * - Listing has NO side effects: reading the directory never deletes.
16
+ */
17
+ export interface SessionRecord {
18
+ addr: string;
19
+ sessionId: string;
20
+ name: string;
21
+ cwd: string;
22
+ pid: number;
23
+ startedAt: number;
24
+ lastSeenAt: number;
25
+ status: 'idle' | 'working';
26
+ offline?: boolean;
27
+ }
28
+ export type Presence = 'live' | 'stalled' | 'offline';
29
+ export declare const HEARTBEAT_STALE_MS = 45000;
30
+ /** A mailbox holding undelivered mail is kept this long after last contact. */
31
+ export declare const SWEEP_MAIL_KEEP_MS: number;
32
+ export declare function deriveAddr(cwd: string, sessionId: string): string;
33
+ export declare function ensureRoot(root: string): void;
34
+ export declare function recordPath(root: string, addr: string): string;
35
+ export declare function inboxDir(root: string, addr: string): string;
36
+ export declare function asksDir(root: string, addr: string): string;
37
+ export declare function writeRecord(root: string, record: SessionRecord): void;
38
+ export declare function readRecord(root: string, addr: string): SessionRecord | null;
39
+ /** Read-only listing, oldest first. Never mutates anything. */
40
+ export declare function listRecords(root: string): SessionRecord[];
41
+ export declare function presenceOf(record: SessionRecord, now?: number): Presence;
42
+ /**
43
+ * Reclaim dead sessions' files. Rules (mail outranks tidiness):
44
+ * - a running session is never touched;
45
+ * - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
46
+ * - an offline but resumable session keeps its record (its address — new
47
+ * mail must remain deliverable while it's down);
48
+ * - only an empty mailbox of a session that can no longer be resumed is
49
+ * discarded promptly.
50
+ *
51
+ * `sessionExists(sessionId)` reports whether pi can still resume the
52
+ * session (its session file is present). When omitted, every offline
53
+ * session is treated as resumable (the conservative choice).
54
+ */
55
+ export declare function sweep(root: string, now?: number, sessionExists?: (sessionId: string) => boolean): void;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Session registry for the relay mailbox: who is around, where, and
3
+ * whether they're reachable. Pi-free — importable from tests without pi's
4
+ * loader.
5
+ *
6
+ * Design (from pi-peer's ARCHITECTURE.md):
7
+ * - An address belongs to a CONVERSATION, not a process: hash of cwd + pi
8
+ * session id, so a resumed session (`pi -c`) answers to the same address
9
+ * and two sessions on one directory never share an inbox.
10
+ * - A record outlives the process that wrote it — that's what makes a
11
+ * session addressable while it's down (mail waits on disk).
12
+ * - Presence is a pid PLUS a heartbeat: pid alone can't tell wedged from
13
+ * healthy (and pids get reused); heartbeat alone can't tell crash from
14
+ * pause.
15
+ * - Listing has NO side effects: reading the directory never deletes.
16
+ */
17
+ import * as crypto from 'node:crypto';
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ export const HEARTBEAT_STALE_MS = 45_000;
21
+ /** A mailbox holding undelivered mail is kept this long after last contact. */
22
+ export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
23
+ export function deriveAddr(cwd, sessionId) {
24
+ return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
25
+ }
26
+ export function ensureRoot(root) {
27
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
28
+ try {
29
+ fs.chmodSync(root, 0o700);
30
+ }
31
+ catch {
32
+ // best-effort on filesystems that support modes
33
+ }
34
+ }
35
+ export function recordPath(root, addr) {
36
+ return path.join(root, `${addr}.json`);
37
+ }
38
+ export function inboxDir(root, addr) {
39
+ return path.join(root, `${addr}.inbox`);
40
+ }
41
+ export function asksDir(root, addr) {
42
+ return path.join(root, `${addr}.asks`);
43
+ }
44
+ export function writeRecord(root, record) {
45
+ ensureRoot(root);
46
+ const file = recordPath(root, record.addr);
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
49
+ fs.renameSync(tmp, file);
50
+ }
51
+ export function readRecord(root, addr) {
52
+ try {
53
+ const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
54
+ if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
55
+ return parsed;
56
+ }
57
+ return null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Read-only listing, oldest first. Never mutates anything. */
64
+ export function listRecords(root) {
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(root, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ const out = [];
73
+ for (const entry of entries) {
74
+ if (!entry.isFile() || !entry.name.endsWith('.json'))
75
+ continue;
76
+ const record = readRecord(root, entry.name.slice(0, -'.json'.length));
77
+ if (record)
78
+ out.push(record);
79
+ }
80
+ return out.sort((a, b) => a.startedAt - b.startedAt);
81
+ }
82
+ function pidAlive(pid) {
83
+ if (!Number.isInteger(pid) || pid <= 0)
84
+ return false;
85
+ try {
86
+ process.kill(pid, 0);
87
+ return true;
88
+ }
89
+ catch (err) {
90
+ // EPERM means the process exists but isn't ours — still alive
91
+ return err?.code === 'EPERM';
92
+ }
93
+ }
94
+ export function presenceOf(record, now = Date.now()) {
95
+ if (record.offline)
96
+ return 'offline';
97
+ if (!pidAlive(record.pid))
98
+ return 'offline';
99
+ return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
100
+ }
101
+ function dirHasJson(dir) {
102
+ try {
103
+ return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ function rmrf(target) {
110
+ try {
111
+ fs.rmSync(target, { recursive: true, force: true });
112
+ }
113
+ catch {
114
+ // sweep is best-effort
115
+ }
116
+ }
117
+ /**
118
+ * Reclaim dead sessions' files. Rules (mail outranks tidiness):
119
+ * - a running session is never touched;
120
+ * - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
121
+ * - an offline but resumable session keeps its record (its address — new
122
+ * mail must remain deliverable while it's down);
123
+ * - only an empty mailbox of a session that can no longer be resumed is
124
+ * discarded promptly.
125
+ *
126
+ * `sessionExists(sessionId)` reports whether pi can still resume the
127
+ * session (its session file is present). When omitted, every offline
128
+ * session is treated as resumable (the conservative choice).
129
+ */
130
+ export function sweep(root, now = Date.now(), sessionExists) {
131
+ for (const record of listRecords(root)) {
132
+ if (presenceOf(record, now) !== 'offline')
133
+ continue;
134
+ const hasMail = dirHasJson(inboxDir(root, record.addr)) || dirHasJson(asksDir(root, record.addr));
135
+ const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
136
+ if (hasMail && !expired)
137
+ continue;
138
+ if (!expired && (sessionExists?.(record.sessionId) ?? true))
139
+ continue;
140
+ rmrf(inboxDir(root, record.addr));
141
+ rmrf(asksDir(root, record.addr));
142
+ try {
143
+ fs.unlinkSync(recordPath(root, record.addr));
144
+ }
145
+ catch {
146
+ // already gone
147
+ }
148
+ }
149
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Relay mechanism tests — tmp dirs, no pi imports. The suite is the
3
+ * specification: each case pins a guarantee from the design.
4
+ */
5
+ export {};