pi-session-mail 0.1.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 ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "pi-session-mail",
3
+ "version": "0.1.0",
4
+ "description": "A file mailbox between Pi sessions on the same machine",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-extension"
9
+ ],
10
+ "license": "MIT",
11
+ "author": "Gage Vander Clay <gvanderclay@gmail.com>",
12
+ "homepage": "https://github.com/gvanderclay/pi-session-mail#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/gvanderclay/pi-session-mail.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/gvanderclay/pi-session-mail/issues"
19
+ },
20
+ "type": "module",
21
+ "engines": {
22
+ "node": ">=22.19.0"
23
+ },
24
+ "packageManager": "pnpm@10.33.0",
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ]
29
+ },
30
+ "files": [
31
+ "index.ts",
32
+ "store.ts",
33
+ "running.ts",
34
+ "tools.ts",
35
+ "README.md",
36
+ "LICENSE",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "scripts": {
40
+ "lint": "biome ci .",
41
+ "typecheck": "tsc -p .",
42
+ "test": "node --test test/*.test.ts",
43
+ "check": "pnpm lint && pnpm typecheck && pnpm test",
44
+ "release": "node scripts/release.mjs"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "typebox": "*"
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "2.5.14",
55
+ "@earendil-works/pi-coding-agent": "1.0.0",
56
+ "@types/node": "26.6.3",
57
+ "typebox": "1.3.27",
58
+ "typescript": "7.0.2"
59
+ }
60
+ }
package/running.ts ADDED
@@ -0,0 +1,156 @@
1
+ // Running-session records and `to` resolution. Internal to `pi-session-mail`: tests
2
+ // reach it only through the extension's registration function.
3
+ //
4
+ // Each running session announces itself in `<root>/running/<address>.json`:
5
+ // its address, Pi session name, working directory, process id, idle or busy,
6
+ // the address it waits on, and when the record last changed. A record whose
7
+ // process is gone counts as not running, and the reader that finds it deletes
8
+ // it. The root is the mail root, so every agent directory sees every session.
9
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { isAddress, mailRoot } from "./store.ts";
13
+
14
+ export type State = "idle" | "busy";
15
+
16
+ export type RunningRecord = {
17
+ address: string;
18
+ /** The Pi session name; absent when none is set. */
19
+ name?: string;
20
+ cwd: string;
21
+ pid: number;
22
+ state: State;
23
+ /** The address this session's pending ask waits on; empty when none. */
24
+ waitingOn: string;
25
+ /** When the record last changed, as an ISO timestamp. */
26
+ updated: string;
27
+ };
28
+
29
+ /** How many leading characters of a session id name it in lists and errors. */
30
+ export const SHORT_ID = 8;
31
+
32
+ export const shortId = (address: string) => address.slice(0, SHORT_ID);
33
+
34
+ /** A record's display label: its session name, or its short id when it has none. */
35
+ export const label = (record: Pick<RunningRecord, "address" | "name">) => record.name ?? shortId(record.address);
36
+
37
+ const runningDir = () => join(mailRoot(), "running");
38
+
39
+ const recordPath = (address: string) => {
40
+ if (!isAddress(address)) throw new Error(`invalid mailbox address: ${JSON.stringify(address)}`);
41
+ return join(runningDir(), `${address}.json`);
42
+ };
43
+
44
+ /** Write `record` owner-only, through a temporary file so a reader never sees half of it. */
45
+ export function writeRecord(record: RunningRecord): void {
46
+ mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
47
+ const path = recordPath(record.address);
48
+ const tmp = `${path}.${process.pid}.tmp`;
49
+ writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
50
+ renameSync(tmp, path);
51
+ }
52
+
53
+ /** Remove `address`'s record, unless another process has since taken it over. */
54
+ export function removeRecord(address: string): void {
55
+ const path = recordPath(address);
56
+ const current = readRecord(path);
57
+ if (current !== undefined && current.pid !== process.pid) return;
58
+ rmSync(path, { force: true });
59
+ }
60
+
61
+ /** Whether a process with this id exists; one we may not signal still exists. */
62
+ function alive(pid: number): boolean {
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (err) {
67
+ return (err as NodeJS.ErrnoException).code === "EPERM";
68
+ }
69
+ }
70
+
71
+ function readRecord(path: string): RunningRecord | undefined {
72
+ try {
73
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<RunningRecord>;
74
+ if (!isAddress(value.address)) return undefined;
75
+ if (typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) return undefined;
76
+ if (typeof value.cwd !== "string") return undefined;
77
+ return {
78
+ address: value.address,
79
+ ...(typeof value.name === "string" && value.name !== "" ? { name: value.name } : {}),
80
+ cwd: value.cwd,
81
+ pid: value.pid,
82
+ state: value.state === "busy" ? "busy" : "idle",
83
+ waitingOn: typeof value.waitingOn === "string" ? value.waitingOn : "",
84
+ updated: typeof value.updated === "string" ? value.updated : "",
85
+ };
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** Every running session's record, sorted by label; records of dead processes are deleted. */
92
+ export function listRunning(): RunningRecord[] {
93
+ let names: string[];
94
+ try {
95
+ names = readdirSync(runningDir()).filter((name) => name.endsWith(".json"));
96
+ } catch {
97
+ return [];
98
+ }
99
+ const out: RunningRecord[] = [];
100
+ for (const name of names) {
101
+ const path = join(runningDir(), name);
102
+ const record = readRecord(path);
103
+ if (record === undefined || `${record.address}.json` !== name) continue; // not ours to judge or delete
104
+ if (!alive(record.pid)) {
105
+ rmSync(path, { force: true });
106
+ continue;
107
+ }
108
+ out.push(record);
109
+ }
110
+ return out.sort((a, b) => label(a).localeCompare(label(b)) || a.address.localeCompare(b.address));
111
+ }
112
+
113
+ /** A Pi session id as Pi writes it: a UUID. */
114
+ const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
115
+
116
+ const candidates = (records: readonly RunningRecord[]) =>
117
+ records.map((record) => `${label(record)} (${record.address})`).join(", ");
118
+
119
+ /**
120
+ * Resolve a `to` typed by a person or a model to an address.
121
+ *
122
+ * A running session's address, or any full session id, resolves as given, so a
123
+ * closed session is still reached by its full id. Otherwise `to` matches
124
+ * running sessions by exact name, then by an id prefix of at least 8
125
+ * characters. Several matches, no match, and `me` itself are refused with an
126
+ * error that says why.
127
+ */
128
+ export function resolveTo(to: string, me: string): string {
129
+ const wanted = to.trim();
130
+ if (wanted === "") throw new Error("no recipient given");
131
+ const running = listRunning();
132
+ let address: string | undefined;
133
+ if (running.some((record) => record.address === wanted) || (FULL_ID.test(wanted) && isAddress(wanted))) {
134
+ address = wanted;
135
+ } else {
136
+ const byName = running.filter((record) => record.name === wanted);
137
+ const byPrefix =
138
+ byName.length === 0 && wanted.length >= SHORT_ID
139
+ ? running.filter((record) => record.address.startsWith(wanted))
140
+ : [];
141
+ const matches = byName.length > 0 ? byName : byPrefix;
142
+ if (matches.length > 1)
143
+ throw new Error(
144
+ `${JSON.stringify(wanted)} matches several running sessions: ${candidates(matches)}; use a full id`,
145
+ );
146
+ if (matches.length === 0)
147
+ throw new Error(
148
+ `no running session is named ${JSON.stringify(wanted)} or has an id starting with it${
149
+ wanted.length < SHORT_ID ? ` (an id prefix needs at least ${SHORT_ID} characters)` : ""
150
+ }; a closed session is reached by its full id`,
151
+ );
152
+ address = matches[0].address;
153
+ }
154
+ if (address === me) throw new Error("that is this session; a session cannot send mail to itself");
155
+ return address;
156
+ }
package/store.ts ADDED
@@ -0,0 +1,222 @@
1
+ // The mailbox's directories and envelopes on disk. Internal to `pi-session-mail`:
2
+ // tests reach it only through the extension's registration function.
3
+ //
4
+ // Layout: <root>/<address>/{tmp,new,cur,sent}/<ms>-<id>.json, where the root
5
+ // is `$XDG_STATE_HOME/pi-session-mail/` (or `~/.local/state/pi-session-mail/`)
6
+ // and is shared by every Pi agent directory on this machine.
7
+ import { randomUUID } from "node:crypto";
8
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { isAbsolute, join } from "node:path";
11
+
12
+ /** What an envelope is: answered at settle, waited on, plain, or an answer. */
13
+ export type Kind = "request" | "ask" | "message" | "reply";
14
+ const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
15
+
16
+ export type Envelope = {
17
+ id: string;
18
+ from: string;
19
+ to: string;
20
+ kind: Kind;
21
+ /** How many times this chain of mail has woken or steered a session with no person typing. */
22
+ hops: number;
23
+ /** Request ids this envelope answers; empty for a request. */
24
+ in_reply_to: string[];
25
+ status: string;
26
+ ts: string;
27
+ body: string;
28
+ };
29
+
30
+ type Box = "tmp" | "new" | "cur" | "sent";
31
+ const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
32
+
33
+ const ADDRESS = /^[A-Za-z0-9_-]+$/;
34
+
35
+ /** An address is a single safe path segment: letters, digits, `-` and `_`. */
36
+ export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
37
+
38
+ /**
39
+ * The mail root, read at call time so a changed `XDG_STATE_HOME` is honoured.
40
+ * A relative `XDG_STATE_HOME` is ignored, as the XDG spec requires.
41
+ */
42
+ export function mailRoot(): string {
43
+ const state = process.env.XDG_STATE_HOME;
44
+ const base = state !== undefined && isAbsolute(state) ? state : join(homedir(), ".local", "state");
45
+ return join(base, "pi-session-mail");
46
+ }
47
+
48
+ export function boxPath(address: string, box: Box): string {
49
+ if (!isAddress(address)) throw new Error(`invalid mailbox address: ${JSON.stringify(address)}`);
50
+ return join(mailRoot(), address, box);
51
+ }
52
+
53
+ /** Create the root and `address`'s boxes; every directory made here is owner-only. */
54
+ export function ensureBoxes(address: string): void {
55
+ mkdirSync(mailRoot(), { recursive: true, mode: 0o700 });
56
+ for (const box of BOXES) mkdirSync(boxPath(address, box), { recursive: true, mode: 0o700 });
57
+ }
58
+
59
+ // Names sort in send order: a strictly increasing millisecond stamp in this
60
+ // process, zero-padded, then the id.
61
+ let lastMs = 0;
62
+ function stamp(): number {
63
+ lastMs = Math.max(Date.now(), lastMs + 1);
64
+ return lastMs;
65
+ }
66
+
67
+ const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
68
+
69
+ /** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
70
+ export type SendOptions = { kind: Kind; hops: number; inReplyTo?: string[]; status?: string };
71
+
72
+ /**
73
+ * Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
74
+ * `new/`. A request is also copied into the sender's `sent/`. Synchronous,
75
+ * so a `pi.events` caller sees the result as soon as `emit` returns.
76
+ * A reply's `status` is `done` unless given; any other kind's is empty.
77
+ */
78
+ export function send(from: string, to: string, body: string, options: SendOptions): Envelope {
79
+ if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
80
+ if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
81
+ if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
82
+ if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
83
+ const inReplyTo = options.inReplyTo ?? [];
84
+ const ms = stamp();
85
+ const envelope: Envelope = {
86
+ id: randomUUID(),
87
+ from,
88
+ to,
89
+ kind: options.kind,
90
+ hops: options.hops,
91
+ in_reply_to: inReplyTo,
92
+ status: options.kind === "reply" ? (options.status ?? "done") : "",
93
+ ts: new Date(ms).toISOString(),
94
+ body,
95
+ };
96
+ const name = fileName(ms, envelope.id);
97
+ const text = `${JSON.stringify(envelope, null, 2)}\n`;
98
+ ensureBoxes(to);
99
+ const tmp = join(boxPath(to, "tmp"), name);
100
+ writeFileSync(tmp, text);
101
+ renameSync(tmp, join(boxPath(to, "new"), name));
102
+ if (options.kind !== "reply") {
103
+ ensureBoxes(from);
104
+ writeFileSync(join(boxPath(from, "sent"), name), text);
105
+ }
106
+ return envelope;
107
+ }
108
+
109
+ /** Regular `.json` files waiting in `new/`, in send order. */
110
+ export function listNew(address: string): string[] {
111
+ const dir = boxPath(address, "new");
112
+ let names: string[];
113
+ try {
114
+ names = readdirSync(dir);
115
+ } catch {
116
+ return [];
117
+ }
118
+ return names
119
+ .filter((name) => name.endsWith(".json"))
120
+ .filter((name) => {
121
+ try {
122
+ return lstatSync(join(dir, name)).isFile();
123
+ } catch {
124
+ return false;
125
+ }
126
+ })
127
+ .sort();
128
+ }
129
+
130
+ /** Claim a file by renaming it from `new/` into `cur/`; null when another scanner got it first. */
131
+ export function claim(address: string, name: string): string | null {
132
+ const target = join(boxPath(address, "cur"), name);
133
+ try {
134
+ renameSync(join(boxPath(address, "new"), name), target);
135
+ return target;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ /** A request's copy in its sender's `sent/`, with its path. */
142
+ export type SentCopy = { envelope: Envelope; path: string };
143
+
144
+ const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
145
+
146
+ /**
147
+ * Parse an envelope file; throws when it is not a well-formed envelope from a
148
+ * valid address. An envelope written before `kind` and `hops` existed is read
149
+ * as a reply when it answers something and as a request otherwise, with hops 0.
150
+ */
151
+ export function readEnvelope(path: string): Envelope {
152
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
153
+ if (typeof value !== "object" || value === null) throw new Error("not an object");
154
+ if (typeof value.id !== "string" || value.id === "") throw new Error("missing id");
155
+ if (!isAddress(value.from)) throw new Error(`invalid from address ${JSON.stringify(value.from)}`);
156
+ if (!Array.isArray(value.in_reply_to) || !value.in_reply_to.every((id) => typeof id === "string"))
157
+ throw new Error("in_reply_to is not a list of ids");
158
+ if (typeof value.body !== "string") throw new Error("missing body");
159
+ if (value.kind === undefined) value.kind = value.in_reply_to.length > 0 ? "reply" : "request";
160
+ else if (!KINDS.includes(value.kind)) throw new Error(`invalid kind ${JSON.stringify(value.kind)}`);
161
+ if (value.hops === undefined) value.hops = 0;
162
+ else if (!isHops(value.hops)) throw new Error(`invalid hops ${JSON.stringify(value.hops)}`);
163
+ return value as Envelope;
164
+ }
165
+
166
+ /**
167
+ * Find this session's `sent/` copy of the request with envelope id `id`;
168
+ * undefined when no readable copy exists. Only names matching the id are read,
169
+ * because `sent/` is never pruned and grows with the address's age.
170
+ */
171
+ export function findSent(address: string, id: string): SentCopy | undefined {
172
+ const dir = boxPath(address, "sent");
173
+ let names: string[];
174
+ try {
175
+ names = readdirSync(dir).filter((name) => name.endsWith(`-${id}.json`));
176
+ } catch {
177
+ return undefined;
178
+ }
179
+ for (const name of names) {
180
+ const path = join(dir, name);
181
+ try {
182
+ const envelope = readEnvelope(path);
183
+ if (envelope.id === id) return { envelope, path };
184
+ } catch {
185
+ // an unreadable copy is no copy
186
+ }
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function envelopesIn(address: string, box: Box): Partial<Envelope>[] {
192
+ const dir = boxPath(address, box);
193
+ let names: string[];
194
+ try {
195
+ names = readdirSync(dir).filter((name) => name.endsWith(".json"));
196
+ } catch {
197
+ return [];
198
+ }
199
+ const out: Partial<Envelope>[] = [];
200
+ for (const name of names) {
201
+ try {
202
+ out.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
203
+ } catch {
204
+ out.push({});
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /**
211
+ * Counts derived from disk: files still in `new/`, envelopes in `cur/`, and
212
+ * requests (not messages) in `sent/` that no envelope in `cur/` answers.
213
+ */
214
+ export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
215
+ const cur = envelopesIn(address, "cur");
216
+ const answered = new Set(cur.flatMap((e) => (Array.isArray(e.in_reply_to) ? e.in_reply_to : [])));
217
+ // A message expects no answer, so it never counts as awaiting one.
218
+ const awaiting = envelopesIn(address, "sent").filter(
219
+ (e) => typeof e.id === "string" && e.kind !== "message" && !answered.has(e.id),
220
+ ).length;
221
+ return { unclaimed: listNew(address).length, read: cur.length, awaiting };
222
+ }