pi-session-mail 0.1.0 → 0.2.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.
@@ -1,11 +1,14 @@
1
1
  // Running-session records and `to` resolution. Internal to `pi-session-mail`: tests
2
- // reach it only through the extension's registration function.
2
+ // reach it through the extension's registration function, apart from the start-token
3
+ // probe and its parsers, which `test/start-token.test.ts` tests directly.
3
4
  //
4
5
  // Each running session announces itself in `<root>/running/<address>.json`:
5
6
  // its address, Pi session name, working directory, process id, idle or busy,
6
7
  // 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.
8
+ // process is gone, or whose pid now belongs to a process that started at
9
+ // another time, counts as not running, and the reader that finds it deletes it.
10
+ // The root is the mail root, so every agent directory sees every session.
11
+ import { execFileSync } from "node:child_process";
9
12
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
13
  import { join } from "node:path";
11
14
 
@@ -19,6 +22,11 @@ export type RunningRecord = {
19
22
  name?: string;
20
23
  cwd: string;
21
24
  pid: number;
25
+ /**
26
+ * An opaque token for the process's start time, so a reused pid is told from the
27
+ * original process. Absent in records written by 0.1.0 and where no probe works.
28
+ */
29
+ started?: string;
22
30
  state: State;
23
31
  /** The address this session's pending ask waits on; empty when none. */
24
32
  waitingOn: string;
@@ -27,12 +35,14 @@ export type RunningRecord = {
27
35
  };
28
36
 
29
37
  /** How many leading characters of a session id name it in lists and errors. */
30
- export const SHORT_ID = 8;
38
+ const SHORT_ID = 8;
31
39
 
32
- export const shortId = (address: string) => address.slice(0, SHORT_ID);
40
+ const shortId = (address: string) => address.slice(0, SHORT_ID);
33
41
 
34
42
  /** 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);
43
+ export function label(record: Pick<RunningRecord, "address" | "name">): string {
44
+ return record.name ?? shortId(record.address);
45
+ }
36
46
 
37
47
  const runningDir = () => join(mailRoot(), "running");
38
48
 
@@ -41,8 +51,78 @@ const recordPath = (address: string) => {
41
51
  return join(runningDir(), `${address}.json`);
42
52
  };
43
53
 
44
- /** Write `record` owner-only, through a temporary file so a reader never sees half of it. */
45
- export function writeRecord(record: RunningRecord): void {
54
+ /**
55
+ * The start-time token in a `/proc/<pid>/stat` line: field 22. The command name
56
+ * (field 2) is in parentheses and may itself hold spaces and parentheses, so
57
+ * the fields are counted from the last `)`.
58
+ */
59
+ export function parseProcStat(line: string): string | undefined {
60
+ const rest = line.slice(line.lastIndexOf(")") + 1).trim();
61
+ if (rest === "" || !line.includes(")")) return undefined;
62
+ const token = rest.split(/\s+/)[19]; // field 3 is the first after the name
63
+ return token !== undefined && /^\d+$/.test(token) ? token : undefined;
64
+ }
65
+
66
+ /** Parse `ps -o pid=,lstart=` output into a pid -> start token map; unparseable lines are skipped. */
67
+ export function parsePsStarts(output: string): Map<number, string> {
68
+ const out = new Map<number, string>();
69
+ for (const line of output.split("\n")) {
70
+ const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
71
+ if (match !== null) out.set(Number(match[1]), match[2]);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * The start tokens of `pids`, for those the probe could read. Linux reads
78
+ * `/proc`; macOS runs one `ps` for all of them, in UTC and the C locale so
79
+ * every session renders a start time the same way; elsewhere, or on any error,
80
+ * the result lacks the pid, which callers treat as unknown.
81
+ */
82
+ export function startTokens(pids: readonly number[]): Map<number, string> {
83
+ const out = new Map<number, string>();
84
+ if (pids.length === 0) return out;
85
+ if (process.platform === "linux") {
86
+ for (const pid of pids) {
87
+ try {
88
+ const token = parseProcStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
89
+ if (token !== undefined) out.set(pid, token);
90
+ } catch {}
91
+ }
92
+ } else if (process.platform === "darwin") {
93
+ let stdout = "";
94
+ try {
95
+ stdout = execFileSync("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
96
+ encoding: "utf8",
97
+ timeout: 2000,
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
100
+ });
101
+ } catch (err) {
102
+ // ps exits 1 when some pid is gone but still prints the others.
103
+ const partial = (err as { stdout?: unknown }).stdout;
104
+ if (typeof partial === "string") stdout = partial;
105
+ }
106
+ for (const [pid, token] of parsePsStarts(stdout)) out.set(pid, token);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ let ownToken: { value: string | undefined } | undefined;
112
+
113
+ /** This process's start token, probed once; undefined when it cannot be read. */
114
+ function ownStartToken(): string | undefined {
115
+ ownToken ??= { value: startTokens([process.pid]).get(process.pid) };
116
+ return ownToken.value;
117
+ }
118
+
119
+ /**
120
+ * Write `record` owner-only, through a temporary file so a reader never sees half of it.
121
+ * A record for this process gets this process's start token.
122
+ */
123
+ export function writeRecord(given: RunningRecord): void {
124
+ const started = given.pid === process.pid && given.started === undefined ? ownStartToken() : undefined;
125
+ const record = started === undefined ? given : { ...given, started };
46
126
  mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
47
127
  const path = recordPath(record.address);
48
128
  const tmp = `${path}.${process.pid}.tmp`;
@@ -79,6 +159,7 @@ function readRecord(path: string): RunningRecord | undefined {
79
159
  ...(typeof value.name === "string" && value.name !== "" ? { name: value.name } : {}),
80
160
  cwd: value.cwd,
81
161
  pid: value.pid,
162
+ ...(typeof value.started === "string" && value.started !== "" ? { started: value.started } : {}),
82
163
  state: value.state === "busy" ? "busy" : "idle",
83
164
  waitingOn: typeof value.waitingOn === "string" ? value.waitingOn : "",
84
165
  updated: typeof value.updated === "string" ? value.updated : "",
@@ -88,6 +169,24 @@ function readRecord(path: string): RunningRecord | undefined {
88
169
  }
89
170
  }
90
171
 
172
+ /**
173
+ * Whether `address` has a live running record, read fresh from its one file. The
174
+ * rules are `listRunning`'s: the pid is alive and, when both tokens are known,
175
+ * the start token matches. A missing or unreadable record is not running; a
176
+ * failed probe falls back to the pid alone. Never throws and deletes nothing.
177
+ */
178
+ export function isRunning(address: string): boolean {
179
+ try {
180
+ const record = readRecord(recordPath(address));
181
+ if (record === undefined || record.address !== address || !alive(record.pid)) return false;
182
+ if (record.started === undefined) return true;
183
+ const live = startTokens([record.pid]).get(record.pid);
184
+ return live === undefined || live === record.started;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
91
190
  /** Every running session's record, sorted by label; records of dead processes are deleted. */
92
191
  export function listRunning(): RunningRecord[] {
93
192
  let names: string[];
@@ -96,12 +195,21 @@ export function listRunning(): RunningRecord[] {
96
195
  } catch {
97
196
  return [];
98
197
  }
99
- const out: RunningRecord[] = [];
198
+ const found: { path: string; record: RunningRecord }[] = [];
100
199
  for (const name of names) {
101
200
  const path = join(runningDir(), name);
102
201
  const record = readRecord(path);
103
202
  if (record === undefined || `${record.address}.json` !== name) continue; // not ours to judge or delete
104
- if (!alive(record.pid)) {
203
+ found.push({ path, record });
204
+ }
205
+ const tokens = startTokens(
206
+ found.filter(({ record }) => record.started !== undefined && alive(record.pid)).map(({ record }) => record.pid),
207
+ );
208
+ const out: RunningRecord[] = [];
209
+ for (const { path, record } of found) {
210
+ const live = tokens.get(record.pid);
211
+ // A token we cannot read is unknown, which falls back to the pid-only check.
212
+ if (!alive(record.pid) || (record.started !== undefined && live !== undefined && live !== record.started)) {
105
213
  rmSync(path, { force: true });
106
214
  continue;
107
215
  }
package/src/settle.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type SettleInput = {
2
+ requests: ReadonlyMap<string, string[]>;
3
+ asks: ReadonlyMap<string, string[]>;
4
+ seen: ReadonlySet<string>;
5
+ held: ReadonlySet<string>;
6
+ end: { kind: "done" } | { kind: "stopped" } | { kind: "failed"; error: string };
7
+ answer: string | undefined;
8
+ };
9
+ export type Reply = { to: string; ids: string[]; body: string; status: "done" | "stopped" | "failed" };
10
+ export type SettleOutput = { replies: Reply[]; owed: Map<string, string[]>; held: Set<string> };
11
+
12
+ /** Reply body when the run ended with no assistant text. */
13
+ export const NO_ANSWER = "(The session settled with no answer text.)";
14
+ /** Reply body of a run the user stopped, with any partial answer text after it. */
15
+ export const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
16
+ /** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
17
+ export const TOOK_OVER =
18
+ "(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
19
+ /** Opening line of the reply body of a run that ended on an error. */
20
+ export const ERRORED = (error: string): string =>
21
+ `(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
22
+ /** Body of a `failed` reply to requests that never entered the conversation. */
23
+ export const UNSEEN =
24
+ "(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
25
+
26
+ /** The body and status of the reply to a run that ended this way: how it ended, then what it had. */
27
+ function describeEnd(end: SettleInput["end"], answer: string | undefined): { body: string; status: Reply["status"] } {
28
+ const text = answer ?? NO_ANSWER;
29
+ const withOpening = (opening: string): string => (answer === undefined ? opening : `${opening}\n\n${answer}`);
30
+ if (end.kind === "stopped") return { body: withOpening(STOPPED), status: "stopped" };
31
+ if (end.kind === "failed") return { body: withOpening(ERRORED(end.error)), status: "failed" };
32
+ return { body: text, status: "done" };
33
+ }
34
+
35
+ /** The replies to the asks: read ones get the run's answer, the rest fail as unseen. */
36
+ function askReplies(
37
+ asks: SettleInput["asks"],
38
+ seen: SettleInput["seen"],
39
+ run: Pick<Reply, "body" | "status">,
40
+ ): Reply[] {
41
+ const replies: Reply[] = [];
42
+ for (const [to, ids] of asks) {
43
+ const read = ids.filter((id) => seen.has(id));
44
+ const unread = ids.filter((id) => !seen.has(id));
45
+ if (read.length > 0) replies.push({ to, ids: read, ...run });
46
+ if (unread.length > 0) replies.push({ to, ids: unread, body: UNSEEN, status: "failed" });
47
+ }
48
+ return replies;
49
+ }
50
+
51
+ /**
52
+ * Decide the replies when a run settles, and which requests stay owed.
53
+ *
54
+ * A request the user stopped a run on is held: it stays owed, through stops
55
+ * and failed runs alike, until a run completes, and is then answered "took
56
+ * over". A request or ask never read is failed as unseen. Asks are never held;
57
+ * their asker is blocked waiting.
58
+ */
59
+ export function settle(input: SettleInput): SettleOutput {
60
+ const { end, answer, seen, held: wasHeld } = input;
61
+ const stopped = end.kind === "stopped";
62
+ const failed = end.kind === "failed";
63
+ const { body, status } = describeEnd(end, answer);
64
+ const tookOverBody = `${TOOK_OVER}\n\n${answer ?? NO_ANSWER}`;
65
+ const replies: Reply[] = [];
66
+ const owed = new Map<string, string[]>();
67
+ const held = new Set<string>();
68
+ for (const [to, ids] of input.requests) {
69
+ const unseen = ids.filter((id) => !seen.has(id) && !wasHeld.has(id));
70
+ const keep = ids.filter((id) => !unseen.includes(id) && (stopped || (failed && wasHeld.has(id))));
71
+ const answered = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
72
+ if (keep.length > 0) owed.set(to, keep);
73
+ for (const id of keep) held.add(id);
74
+ const plain = answered.filter((id) => !wasHeld.has(id));
75
+ const tookOver = answered.filter((id) => wasHeld.has(id));
76
+ if (plain.length > 0) replies.push({ to, ids: plain, body, status });
77
+ if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
78
+ if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
79
+ }
80
+ replies.push(...askReplies(input.asks, seen, { body, status }));
81
+ return { replies, owed, held };
82
+ }
package/src/store.ts ADDED
@@ -0,0 +1,412 @@
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 (`settle.ts` is
3
+ // the one module tested directly).
4
+ //
5
+ // Layout: <root>/<address>/{tmp,new,cur,sent}/<ms>-<id>.json, where the root
6
+ // is `$XDG_STATE_HOME/pi-session-mail/` (or `~/.local/state/pi-session-mail/`)
7
+ // and is shared by every Pi agent directory on this machine.
8
+ import { randomUUID } from "node:crypto";
9
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, join } from "node:path";
12
+
13
+ /** What an envelope is: answered at settle, waited on, plain, or an answer. */
14
+ export type Kind = "request" | "ask" | "message" | "reply";
15
+ const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
16
+
17
+ export type Envelope = {
18
+ id: string;
19
+ from: string;
20
+ to: string;
21
+ kind: Kind;
22
+ /** How many times this chain of mail has woken or steered a session with no person typing. */
23
+ hops: number;
24
+ /** Request ids this envelope answers; empty for a request. */
25
+ in_reply_to: string[];
26
+ status: string;
27
+ ts: string;
28
+ body: string;
29
+ };
30
+
31
+ type Box = "tmp" | "new" | "cur" | "sent";
32
+ const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
33
+
34
+ const ADDRESS = /^[A-Za-z0-9_-]+$/;
35
+
36
+ /** The most requests one reply may name; an envelope over this is not read. */
37
+ const MAX_REPLY_IDS = 50;
38
+
39
+ /** An address is a single safe path segment: letters, digits, `-` and `_`. */
40
+ export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
41
+
42
+ /**
43
+ * The mail root, read at call time so a changed `XDG_STATE_HOME` is honoured.
44
+ * A relative `XDG_STATE_HOME` is ignored, as the XDG spec requires.
45
+ */
46
+ export function mailRoot(): string {
47
+ const state = process.env.XDG_STATE_HOME;
48
+ const base = state !== undefined && isAbsolute(state) ? state : join(homedir(), ".local", "state");
49
+ return join(base, "pi-session-mail");
50
+ }
51
+
52
+ export function boxPath(address: string, box: Box): string {
53
+ if (!isAddress(address)) throw new Error(`invalid mailbox address: ${JSON.stringify(address)}`);
54
+ return join(mailRoot(), address, box);
55
+ }
56
+
57
+ /** Create the root and `address`'s boxes; the root is made or set owner-only, and so is every directory made here. */
58
+ export function ensureBoxes(address: string): void {
59
+ mkdirSync(mailRoot(), { recursive: true, mode: 0o700 });
60
+ // mkdirSync leaves a directory that already exists as it was. A root this
61
+ // user cannot chmod (another owner, a read-only mount) is used as it is.
62
+ try {
63
+ chmodSync(mailRoot(), 0o700);
64
+ } catch {
65
+ // best effort
66
+ }
67
+ for (const box of BOXES) mkdirSync(boxPath(address, box), { recursive: true, mode: 0o700 });
68
+ }
69
+
70
+ // Names sort in send order: a strictly increasing millisecond stamp in this
71
+ // process, zero-padded, then the id.
72
+ let lastMs = 0;
73
+ function stamp(): number {
74
+ lastMs = Math.max(Date.now(), lastMs + 1);
75
+ return lastMs;
76
+ }
77
+
78
+ const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
79
+
80
+ /** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
81
+ export type SendOptions = { kind: Kind; hops: number; inReplyTo?: string[]; status?: string };
82
+
83
+ /**
84
+ * Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
85
+ * `new/`. A request is also copied into the sender's `sent/`, first; if any
86
+ * step fails the copy is removed again and the send throws, so a thrown send
87
+ * has delivered nothing. Synchronous,
88
+ * so a `pi.events` caller sees the result as soon as `emit` returns.
89
+ * A reply's `status` is `done` unless given; any other kind's is empty.
90
+ */
91
+ export function send(from: string, to: string, body: string, options: SendOptions): Envelope {
92
+ if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
93
+ if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
94
+ if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
95
+ if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
96
+ const inReplyTo = options.inReplyTo ?? [];
97
+ const ms = stamp();
98
+ const envelope: Envelope = {
99
+ id: randomUUID(),
100
+ from,
101
+ to,
102
+ kind: options.kind,
103
+ hops: options.hops,
104
+ in_reply_to: inReplyTo,
105
+ status: options.kind === "reply" ? (options.status ?? "done") : "",
106
+ ts: new Date(ms).toISOString(),
107
+ body,
108
+ };
109
+ const name = fileName(ms, envelope.id);
110
+ const text = `${JSON.stringify(envelope, null, 2)}\n`;
111
+ ensureBoxes(to);
112
+ const tmp = join(boxPath(to, "tmp"), name);
113
+ // The sender's copy goes first, so a send that throws has delivered nothing:
114
+ // a failure after it removes the copy and the `tmp/` file again.
115
+ let copy: string | undefined;
116
+ try {
117
+ if (options.kind !== "reply") {
118
+ ensureBoxes(from);
119
+ copy = join(boxPath(from, "sent"), name);
120
+ writeFileSync(copy, text, { mode: 0o600 });
121
+ }
122
+ writeFileSync(tmp, text, { mode: 0o600 });
123
+ renameSync(tmp, join(boxPath(to, "new"), name));
124
+ } catch (err) {
125
+ for (const path of [copy, tmp]) if (path !== undefined) rmSync(path, { force: true });
126
+ throw err;
127
+ }
128
+ return envelope;
129
+ }
130
+
131
+ /** A regular file, the only kind of name worth reading; a FIFO, directory or symlink is not. */
132
+ function isFile(path: string): boolean {
133
+ try {
134
+ return lstatSync(path).isFile();
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /** `.json` names in `box` that are regular files, in readdir order. */
141
+ function jsonFiles(address: string, box: Box): string[] {
142
+ const dir = boxPath(address, box);
143
+ let names: string[];
144
+ try {
145
+ names = readdirSync(dir);
146
+ } catch {
147
+ return [];
148
+ }
149
+ return names.filter((name) => name.endsWith(".json") && isFile(join(dir, name)));
150
+ }
151
+
152
+ /** Regular `.json` files waiting in `new/`, in send order. */
153
+ export function listNew(address: string): string[] {
154
+ return jsonFiles(address, "new").sort();
155
+ }
156
+
157
+ /** Claim a file by renaming it from `new/` into `cur/`; null when another scanner got it first. */
158
+ export function claim(address: string, name: string): string | null {
159
+ const target = join(boxPath(address, "cur"), name);
160
+ try {
161
+ renameSync(join(boxPath(address, "new"), name), target);
162
+ return target;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /** A request's copy in its sender's `sent/`, with its path. */
169
+ export type SentCopy = { envelope: Envelope; path: string };
170
+
171
+ const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
172
+
173
+ /**
174
+ * Parse an envelope file; throws when it is not a well-formed envelope from a
175
+ * valid address. An envelope written before `kind` and `hops` existed is read
176
+ * as a reply when it answers something and as a request otherwise, with hops 0.
177
+ * A `kind` this version does not know is read as `message`, so mail from a
178
+ * newer copy is delivered rather than stranded; the original value is dropped.
179
+ */
180
+ export function readEnvelope(path: string): Envelope {
181
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
182
+ if (typeof value !== "object" || value === null) throw new Error("not an object");
183
+ if (typeof value.id !== "string" || value.id === "") throw new Error("missing id");
184
+ if (!isAddress(value.from)) throw new Error(`invalid from address ${JSON.stringify(value.from)}`);
185
+ if (!Array.isArray(value.in_reply_to) || !value.in_reply_to.every((id) => typeof id === "string"))
186
+ throw new Error("in_reply_to is not a list of ids");
187
+ if (value.in_reply_to.length > MAX_REPLY_IDS)
188
+ throw new Error(`in_reply_to names more than ${MAX_REPLY_IDS} requests`);
189
+ if (typeof value.body !== "string") throw new Error("missing body");
190
+ if (value.kind === undefined) value.kind = value.in_reply_to.length > 0 ? "reply" : "request";
191
+ else if (!KINDS.includes(value.kind)) value.kind = "message";
192
+ if (value.hops === undefined) value.hops = 0;
193
+ else if (!isHops(value.hops)) throw new Error(`invalid hops ${JSON.stringify(value.hops)}`);
194
+ return value as Envelope;
195
+ }
196
+
197
+ /**
198
+ * This session's `sent/` copies of the requests with envelope ids `ids`, keyed
199
+ * by id; an id with no readable copy is absent. `sent/` is listed once, because
200
+ * it grows with the address's age (until the folder is pruned, which removes
201
+ * it too) and one reply may name many requests. Only names matching an id are read.
202
+ */
203
+ export function findSent(address: string, ids: readonly string[]): Map<string, SentCopy> {
204
+ const wanted = new Set(ids);
205
+ const found = new Map<string, SentCopy>();
206
+ if (wanted.size === 0) return found;
207
+ const dir = boxPath(address, "sent");
208
+ let names: string[];
209
+ try {
210
+ names = readdirSync(dir);
211
+ } catch {
212
+ return found;
213
+ }
214
+ for (const name of names) {
215
+ const id = ids.find((candidate) => name.endsWith(`-${candidate}.json`));
216
+ if (id === undefined || found.has(id)) continue;
217
+ const path = join(dir, name);
218
+ if (!isFile(path)) continue;
219
+ try {
220
+ const envelope = readEnvelope(path);
221
+ if (envelope.id === id) found.set(id, { envelope, path });
222
+ } catch {
223
+ // an unreadable copy is no copy
224
+ }
225
+ }
226
+ return found;
227
+ }
228
+
229
+ function envelopesIn(address: string, box: Box): Partial<Envelope>[] {
230
+ const dir = boxPath(address, box);
231
+ const out: Partial<Envelope>[] = [];
232
+ for (const name of jsonFiles(address, box)) {
233
+ try {
234
+ out.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
235
+ } catch {
236
+ out.push({});
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Counts derived from disk: files still in `new/`, envelopes in `cur/`, and
244
+ * requests (not messages) in `sent/` that no envelope in `cur/` answers.
245
+ */
246
+ export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
247
+ const cur = envelopesIn(address, "cur");
248
+ const answered = new Set(cur.flatMap((e) => (Array.isArray(e.in_reply_to) ? e.in_reply_to : [])));
249
+ // A message expects no answer, so it never counts as awaiting one.
250
+ const awaiting = envelopesIn(address, "sent").filter(
251
+ (e) => typeof e.id === "string" && e.kind !== "message" && !answered.has(e.id),
252
+ ).length;
253
+ return { unclaimed: listNew(address).length, read: cur.length, awaiting };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Pruning
258
+
259
+ /** What decides whether an address folder is pruned. */
260
+ export type FolderFacts = {
261
+ own: boolean;
262
+ running: boolean;
263
+ /** Anything at all waiting in `new/`. */
264
+ unread: boolean;
265
+ /** The newest modification time among the folder and its four boxes, in ms. */
266
+ lastActivityMs: number;
267
+ };
268
+
269
+ /**
270
+ * Whether a folder may be removed: it is not this session's, no live session
271
+ * has it, nothing waits in `new/`, and (given a `cutoffMs`) its last activity
272
+ * is older than the cutoff. Without a cutoff age does not matter.
273
+ */
274
+ export function shouldPrune(facts: FolderFacts, cutoffMs?: number): boolean {
275
+ if (facts.own || facts.running || facts.unread) return false;
276
+ return cutoffMs === undefined || facts.lastActivityMs < cutoffMs;
277
+ }
278
+
279
+ /** The directory `running/` holds the running-session records in; it is never pruned. */
280
+ const RUNNING = "running";
281
+
282
+ /** A folder being pruned is first renamed to this, which is no address, so a send makes a fresh folder. */
283
+ const ASIDE = /^\.pruning\.([A-Za-z0-9_-]+)\.[0-9a-f-]{36}$/;
284
+
285
+ const isDirectory = (path: string): boolean => {
286
+ try {
287
+ return lstatSync(path).isDirectory();
288
+ } catch {
289
+ return false;
290
+ }
291
+ };
292
+
293
+ /** Names in `aside/<box>` when it is a real directory; a symlink or file there lists nothing. Undefined when it cannot be listed. */
294
+ const boxNames = (aside: string, box: Box): string[] | undefined =>
295
+ isDirectory(join(aside, box)) ? namesIn(join(aside, box)) : [];
296
+
297
+ /** Rename `from` to `to`; if a concurrent prune renamed the recreated folder away (ENOENT), make the boxes again and retry once. */
298
+ function moveBack(from: string, to: string, address: string): void {
299
+ try {
300
+ renameSync(from, to);
301
+ } catch (err) {
302
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
303
+ ensureBoxes(address);
304
+ renameSync(from, to);
305
+ }
306
+ }
307
+
308
+ /** Names in `path`; undefined when it cannot be listed. */
309
+ function namesIn(path: string): string[] | undefined {
310
+ try {
311
+ return readdirSync(path);
312
+ } catch (err) {
313
+ return (err as NodeJS.ErrnoException).code === "ENOENT" ? [] : undefined;
314
+ }
315
+ }
316
+
317
+ function lastActivity(folder: string): number {
318
+ let newest = 0;
319
+ for (const path of [folder, ...BOXES.map((box) => join(folder, box))]) {
320
+ try {
321
+ newest = Math.max(newest, lstatSync(path).mtimeMs);
322
+ } catch {
323
+ // a missing box has no activity
324
+ }
325
+ }
326
+ return newest;
327
+ }
328
+
329
+ /**
330
+ * Remove a folder that was renamed aside, unless mail reached its `new/`
331
+ * after the check: then everything in its boxes is moved back into the
332
+ * address's own boxes (made again if need be) and the folder is kept. True
333
+ * when the folder was removed.
334
+ */
335
+ function finishAside(aside: string, address: string): boolean {
336
+ const waiting = boxNames(aside, "new");
337
+ if (waiting === undefined) return false;
338
+ if (waiting.length === 0) {
339
+ rmSync(aside, { recursive: true, force: true });
340
+ return true;
341
+ }
342
+ ensureBoxes(address);
343
+ // `new/` last: if a move fails part-way, `new/` still holds mail, so the next
344
+ // prune restores the rest instead of taking the folder for empty and removing it.
345
+ for (const box of ["cur", "sent", "tmp", "new"] as const)
346
+ for (const name of boxNames(aside, box) ?? [])
347
+ moveBack(join(aside, box, name), join(boxPath(address, box), name), address);
348
+ rmSync(aside, { recursive: true, force: true });
349
+ return false;
350
+ }
351
+
352
+ /** What `pruneClosed` is given. */
353
+ export type PruneOptions = {
354
+ /** This session's address, which is never pruned. */
355
+ own?: string;
356
+ /** Whether a live session has this address; asked again for a candidate just before it is moved aside. */
357
+ isRunning: (address: string) => boolean;
358
+ /** Only folders with no activity since this time (ms since the epoch) are removed; none means any age. */
359
+ cutoffMs?: number;
360
+ /** A test seam, called between a folder's check and its removal, to reach that window. */
361
+ beforeRemove?: (address: string) => void;
362
+ };
363
+
364
+ /**
365
+ * Remove the folders of closed sessions that hold no unread mail, and return
366
+ * how many were removed. Only real directories named like an address are
367
+ * considered (never `running/`, a symlink, or a file). A folder is first
368
+ * renamed to a name that is no address, atomically, so a send arriving after
369
+ * the check recreates a fresh folder instead of writing into one about to
370
+ * go; mail that reached its `new/` before the rename is moved back and the
371
+ * folder is kept. A folder left aside by a crash is finished the same way.
372
+ * Liveness is checked again just before the rename, so a session that started
373
+ * after the first check keeps its folder. An error on one folder skips that
374
+ * folder, which is then not counted; the pass goes on.
375
+ */
376
+ export function pruneClosed(options: PruneOptions): number {
377
+ const root = mailRoot();
378
+ const names = namesIn(root);
379
+ if (names === undefined) throw new Error(`cannot list ${root}`);
380
+ return names.filter((name) => {
381
+ try {
382
+ return pruneOne(root, name, options);
383
+ } catch {
384
+ return false; // one bad folder must not stop the pass
385
+ }
386
+ }).length;
387
+ }
388
+
389
+ /** Prune the entry `name` of the mail root if it is a candidate; true when a folder was removed. */
390
+ function pruneOne(root: string, name: string, options: PruneOptions): boolean {
391
+ const path = join(root, name);
392
+ const leftover = ASIDE.exec(name);
393
+ if (leftover !== null) return isDirectory(path) && finishAside(path, leftover[1]);
394
+ if (name === RUNNING || !isAddress(name) || !isDirectory(path)) return false;
395
+ const unread = namesIn(join(path, "new"));
396
+ const facts: FolderFacts = {
397
+ own: name === options.own,
398
+ running: options.isRunning(name),
399
+ unread: unread === undefined || unread.length > 0,
400
+ lastActivityMs: lastActivity(path),
401
+ };
402
+ if (!shouldPrune(facts, options.cutoffMs)) return false;
403
+ options.beforeRemove?.(name);
404
+ if (options.isRunning(name)) return false; // a session may have started since the first check
405
+ const aside = join(root, `.pruning.${name}.${randomUUID()}`);
406
+ try {
407
+ renameSync(path, aside);
408
+ } catch {
409
+ return false; // gone or busy: leave it
410
+ }
411
+ return finishAside(aside, name);
412
+ }