@volter/supercode-teams 0.3.151 → 0.3.152

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/mail/agents.mjs CHANGED
@@ -2,116 +2,58 @@
2
2
  // docs/adr/0008-agent-mailbox.md). An agent is addressed `sc:<machine>:agent:<name>`; its record names its main session,
3
3
  // where a root addressed to it goes. A thread is a root and its replies, with its participants: a `to` participant
4
4
  // receives each line through its door, woken; a `cc` participant has it filed unread. `plan` decides who receives one
5
- // message. The records are mail_agent.rs's own files (`<mail>/agents/<name>.json`, `<mail>/threads/<id>.json`), written
6
- // as it writes them, under the same lock, since the native verbs that are 3b-2's still read and write them.
7
- import { closeSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
5
+ // message. The daemon owns these records in its mail database. A thread update
6
+ // is one synchronous transaction; no other process opens a thread file or lock.
8
7
  import { spawn } from 'node:child_process';
9
- import { join } from 'node:path';
10
8
  import { blake3Hex } from './envelope.mjs';
11
9
  import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
12
10
 
13
11
  /** The pseudo-harness of an agent address. */
14
12
  export const AGENT_HARNESS = 'agent';
15
- /** How recent a folder time is not trusted to say its folder is unchanged (a coarse clock's tick, a 1 s filesystem). */
16
- const RACY_MS = 2000;
17
13
  /** How long a send waits for a resumed session to be reachable again. */
18
14
  export const RESUME_WAIT_MS = 45_000;
19
15
 
20
16
  const validName = (name) => typeof name === 'string' && /^[A-Za-z0-9._-]+$/.test(name);
21
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
-
23
- /** A record written whole and renamed into place (mail_agent.rs `write_atomically`). */
24
- export function writeAtomically(path, text) {
25
- mkdirSync(join(path, '..'), { recursive: true });
26
- const temporary = `${path.replace(/\.[^./]*$/, '')}.tmp.${process.pid}`;
27
- writeFileSync(temporary, text);
28
- renameSync(temporary, path);
29
- }
30
-
31
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
32
-
33
- /** serde_json's `to_vec_pretty` of a thread, its fields in mail_agent.rs's order and its empty optionals left out. */
34
- function threadText(thread) {
17
+ /** Preserve the former record's fields, leaving its empty optionals out. */
18
+ function threadRecord(thread) {
35
19
  const out = { id: thread.id, agent: thread.agent, holder: thread.holder, participants: thread.participants.map(({ address, role }) => ({ address, role })) };
36
20
  if (thread.markers && Object.keys(thread.markers).length) out.markers = Object.fromEntries(Object.entries(thread.markers).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
37
21
  if (thread.surface != null) out.surface = thread.surface;
38
22
  out.created_at_ms = thread.created_at_ms;
39
23
  if (thread.holder_launched) out.holder_launched = true;
40
24
  if (thread.parent != null) out.parent = thread.parent;
41
- return JSON.stringify(out, null, 2);
25
+ return out;
42
26
  }
43
27
 
44
28
  export class Agents {
45
- constructor({ mailRoot, machine, supercodeBin, env = process.env, recordedConfig = async () => null, discover = async () => ({ sessions: [] }) }) {
46
- Object.assign(this, { mailRoot, machine, supercodeBin, env, recordedConfig, discover });
47
- this.agentsDir = join(mailRoot, 'agents');
48
- this.threadsDir = join(mailRoot, 'threads');
49
- this.locks = new Map(); // thread id → the write in progress here (one writer per thread in this process)
29
+ constructor({ store, machine, supercodeBin, env = process.env, recordedConfig = async () => null, discover = async () => ({ sessions: [] }) }) {
30
+ Object.assign(this, { store, machine, supercodeBin, env, recordedConfig, discover });
50
31
  }
51
32
 
52
33
  // ---- records ---------------------------------------------------------------------------------------------------
53
34
 
54
35
  /** The agent named `name`, when one is declared. */
55
- load(name) { return validName(name) ? readJson(join(this.agentsDir, `${name}.json`)) : null; }
36
+ load(name) { return validName(name) ? this.store.record('agents', name) : null; }
56
37
 
57
38
  /** Every declared agent, by name. */
58
39
  all() {
59
- let names = [];
60
- try { names = readdirSync(this.agentsDir); } catch { return []; }
61
- return names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.agentsDir, name))).filter((agent) => agent?.name && agent?.main_session).sort((a, b) => (a.name < b.name ? -1 : 1));
40
+ return this.store.records('agents').map(({ value }) => value).filter((agent) => agent?.name && agent?.main_session).sort((a, b) => (a.name < b.name ? -1 : 1));
62
41
  }
63
42
 
64
43
  /** The thread `id`, when the mailbox keeps one. */
65
- thread(id) { return validName(id) ? readJson(join(this.threadsDir, `${id}.json`)) : null; }
44
+ thread(id) { return validName(id) ? this.store.record('threads', id) : null; }
66
45
 
67
46
  /** Every thread, oldest first. */
68
- threads() { return this.#records().list.map((thread) => structuredClone(thread)); }
69
-
70
- /**
71
- * The thread records, read once and kept while their folder is unchanged, with an index of them by holder and by
72
- * channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
73
- * record into the folder, which moves its time: one stat a call, never a read of every record, though a send's plan
74
- * asks on every send and an agent's threads grow with its history. A folder time within RACY_MS of now is not trusted
75
- * (a write in the same tick of a coarse clock leaves it unmoved): the folder is listed again, each record checked by
76
- * its inode, until its time is older.
77
- */
78
- #records() {
79
- let changed;
80
- try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
81
- const cache = this.threadsCache;
82
- if (cache?.changed === changed && Date.now() - changed > RACY_MS) return cache;
83
- // The folder changed, or changed too recently for its time to be trusted: its names are listed and each record's
84
- // file is stat'ed, and only a record whose inode moved (every writer renames a whole new file into place) is read
85
- // again. A burst of writes costs a listing and a stat a record, not a read of every record.
86
- let names = [];
87
- try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
88
- const files = new Map();
89
- for (const name of names) {
90
- if (!name.endsWith('.json')) continue;
91
- // its inode, time and size together (an inode can be used again once freed): a rename moves at least one
92
- let stamp;
93
- try { const stat = statSync(join(this.threadsDir, name)); stamp = `${stat.ino}:${stat.mtimeMs}:${stat.size}`; } catch { continue; }
94
- const known = cache?.files?.get(name);
95
- const thread = known && known.stamp === stamp ? known.thread : readJson(join(this.threadsDir, name));
96
- if (thread?.id) files.set(name, { stamp, thread });
97
- }
98
- const list = [...files.values()].map((file) => file.thread)
99
- .sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
100
- const byHolder = new Map(), byMarker = new Map();
101
- for (const thread of list) {
102
- byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
103
- for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
104
- }
105
- const records = { changed, list, byHolder, byMarker, files };
106
- // kept either way (its records are each current by inode); trusted without a listing only once its time is old
107
- this.threadsCache = records;
108
- return records;
47
+ threads() {
48
+ return this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' ORDER BY json_extract(record, '$.created_at_ms'), json_extract(record, '$.id')")
49
+ .all().map(({ record }) => JSON.parse(record)).filter((thread) => thread?.id);
109
50
  }
110
51
 
111
52
 
112
53
  /** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
113
54
  heldBy(address) {
114
- const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
55
+ const held = this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' AND json_extract(record, '$.holder') = ? ORDER BY json_extract(record, '$.created_at_ms') DESC, json_extract(record, '$.id') DESC")
56
+ .all(address).map(({ record }) => JSON.parse(record)).find((thread) => {
115
57
  if (thread.parent != null) return false;
116
58
  const agent = this.load(thread.agent);
117
59
  return agent != null && agent.main_session !== address;
@@ -121,8 +63,10 @@ export class Agents {
121
63
 
122
64
  /** The thread whose channel message has the platform id `marker`, with that message's id. */
123
65
  ofMarker(marker) {
124
- const found = this.#records().byMarker.get(marker);
125
- return found ? { thread: structuredClone(found.thread), id: found.id } : null;
66
+ const found = this.store.statement(`SELECT r.record, m.message FROM thread_markers m
67
+ JOIN mail_records r ON r.collection = m.collection AND r.key = m.thread
68
+ WHERE m.marker = ? ORDER BY json_extract(r.record, '$.created_at_ms'), json_extract(r.record, '$.id') LIMIT 1`).get(marker);
69
+ return found ? { thread: JSON.parse(found.record), id: JSON.parse(found.message) } : null;
126
70
  }
127
71
 
128
72
  /** The agent `address` is a session of: its main, or a thread's holder. */
@@ -133,46 +77,17 @@ export class Agents {
133
77
  return held ? this.load(held.agent) : null;
134
78
  }
135
79
 
136
- /**
137
- * Change the thread `id` under its lock (`<id>.lock`, made with create-new; one older than 10 s is a dead writer's;
138
- * a live one is waited on for 30 s, then the send fails), and answer it as saved; `create()` makes it when it is not
139
- * kept yet (mail_agent.rs `update_thread`).
140
- */
80
+ /** Change a thread and its marker index together; `create()` supplies a missing
81
+ * record. No native-door await or external writer belongs in this transaction. */
141
82
  async update(id, create, change) {
142
83
  if (!validName(id)) return null;
143
- const previous = this.locks.get(id) ?? Promise.resolve();
144
- let release;
145
- const mine = new Promise((resolve) => { release = resolve; });
146
- const chained = previous.then(() => mine);
147
- this.locks.set(id, chained);
148
- await previous;
149
- try {
150
- mkdirSync(this.threadsDir, { recursive: true });
151
- const path = join(this.threadsDir, `${id}.json`);
152
- const lock = join(this.threadsDir, `${id}.lock`);
153
- const started = Date.now();
154
- for (;;) {
155
- try { closeSync(openSync(lock, 'wx')); break; }
156
- catch (error) {
157
- if (error.code !== 'EEXIST') throw error;
158
- let stale = false;
159
- try { stale = Date.now() - statSync(lock).mtimeMs > 10_000; } catch { /* gone meanwhile */ }
160
- if (stale) { try { unlinkSync(lock); } catch { /* another took it */ } }
161
- else if (Date.now() - started > 30_000) throw new Error(`thread ${id} is locked by another writer`);
162
- await sleep(20);
163
- }
164
- }
165
- try {
166
- const thread = readJson(path) ?? create();
167
- if (!thread) return null;
168
- change(thread);
169
- writeAtomically(path, threadText(thread));
170
- return thread;
171
- } finally { try { unlinkSync(lock); } catch { /* released */ } }
172
- } finally {
173
- release();
174
- if (this.locks.get(id) === chained) this.locks.delete(id);
175
- }
84
+ return this.store.transaction(() => {
85
+ const thread = this.thread(id) ?? create();
86
+ if (!thread) return null;
87
+ if (change(thread)?.then instanceof Function) throw new Error('mail thread updates must be synchronous');
88
+ this.store.putRecord('threads', id, threadRecord(thread));
89
+ return thread;
90
+ });
176
91
  }
177
92
 
178
93
  /** Record `agent`, replacing an earlier record of the same name (mail_agent.rs `declare`): its fields in its order, an
@@ -183,19 +98,23 @@ export class Agents {
183
98
  if (agent.folder != null) out.folder = agent.folder;
184
99
  if (agent.harness != null) out.harness = agent.harness;
185
100
  if (agent.idle_minutes != null) out.idle_minutes = agent.idle_minutes;
186
- writeAtomically(join(this.agentsDir, `${agent.name}.json`), JSON.stringify(out, null, 2));
101
+ this.store.putRecord('agents', agent.name, out);
187
102
  }
188
103
 
189
104
  /** Name `name` the owner's account manager: every line the owner writes to any agent is CC'd to its main session. */
190
- setOwnersAccountManager(name) { writeAtomically(join(this.agentsDir, 'owners-account-manager'), `${name}\n`); }
105
+ setOwnersAccountManager(name) { this.store.putRecord('agent_settings', 'owners-account-manager', name); }
191
106
 
192
107
  /** The owner's account manager, when one is named and declared. */
193
108
  ownersAccountManager() {
194
- try { return this.load(readFileSync(join(this.agentsDir, 'owners-account-manager'), 'utf8').trim()); } catch { return null; }
109
+ const name = this.store.record('agent_settings', 'owners-account-manager');
110
+ return typeof name === 'string' ? this.load(name.trim()) : null;
195
111
  }
196
112
 
197
113
  /** The threads of the agent `name`, oldest first. */
198
- threadsOf(name) { return this.#records().list.filter((thread) => thread.agent === name).map((thread) => structuredClone(thread)); }
114
+ threadsOf(name) {
115
+ return this.store.statement("SELECT record FROM mail_records WHERE collection = 'threads' AND json_extract(record, '$.agent') = ? ORDER BY json_extract(record, '$.created_at_ms'), json_extract(record, '$.id')")
116
+ .all(name).map(({ record }) => JSON.parse(record)).filter((thread) => thread?.id);
117
+ }
199
118
 
200
119
  /**
201
120
  * A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
@@ -346,6 +265,7 @@ export class Agents {
346
265
  * <its recorded mode>`: never open's unattended defaults (mail_agent.rs `resume`), and no window on the screen. */
347
266
  async resume(address) {
348
267
  const recorded = await this.#resumeArguments(address);
268
+ if (this.stopped) throw new Error('the daemon is stopping; no agent session was resumed');
349
269
  if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
350
270
  const parsed = parseAddress(address);
351
271
  // a Claude holder comes back under a name of its own (its agent's, and its id's start), never its folder's, which
@@ -354,7 +274,7 @@ export class Agents {
354
274
  const thread = this.threads().find((item) => item.holder === address);
355
275
  if (thread) recorded.push('--name', `${thread.agent}-${[...parsed.session].slice(0, 6).join('')}`);
356
276
  }
357
- writeAtomically(join(this.agentsDir, 'resumed', blake3Hex(address).slice(0, 24)), String(Date.now()));
277
+ this.store.putRecord('agent_resumed', blake3Hex(address).slice(0, 24), String(Date.now()));
358
278
  const { code, stderr } = await new Promise((resolve) => {
359
279
  // a holder resumed by mail runs in the background: no window opens on the machine's screen for it
360
280
  const child = spawn(this.supercodeBin, ['open', parsed.session, '--detach', '--window=false', '--', ...recorded], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
@@ -382,6 +302,8 @@ export class Agents {
382
302
  if (config?.session_id !== parsed.session) return null;
383
303
  return resumeArguments(config);
384
304
  }
305
+
306
+ stop() { this.stopped = true; }
385
307
  }
386
308
 
387
309
  /** A recorded configuration as resume arguments, or null when it is of a kind not carried (mail_agent.rs). */
@@ -416,4 +338,3 @@ export function resumeArguments(config) {
416
338
  if (model) args.push('--model', model);
417
339
  return args;
418
340
  }
419
-
package/mail/carrier.mjs CHANGED
@@ -1,20 +1,18 @@
1
1
  // The mailbox's own delivery (D140; docs/architecture/overview.md, step 4b): every message filed with a wake (by a filer
2
2
  // that does not wait, a board's dispatcher, a send to a hooked session) is handed to its session by the session's own
3
3
  // door, by this daemon. Mail for another machine is carried there through its mail door and filed with the same wake,
4
- // so that machine's daemon delivers it. The native watch did this by reading every mailbox every two seconds
5
- // (mail_watch.rs `MailCarrier`); here a wake is followed from its own file event (one read of the mail folder at start),
6
- // and each is attempted when it is due. Its rules and its records are the native carrier's, file for file:
7
- // - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself (`wake/<id>`);
4
+ // so that machine's daemon delivers it. A wake is a row of the mail store (mail step 2), followed from the write that
5
+ // makes it (the store's `wake` event; every wake read at start), and each is attempted when it is due. Its rules are the
6
+ // native carrier's (mail_watch.rs `MailCarrier`, deleted):
7
+ // - a failed attempt waits FIRST_RETRY_MS doubling to LAST_RETRY_MS, kept in the wake itself;
8
8
  // - a wake expires once it has failed WAKE_ATTEMPTS counted times and its first counted failure is older than its bound
9
9
  // (LOCAL_WAKE_LIFETIME_MS here, REMOTE_WAKE_LIFETIME_MS for another machine): the message stays filed and unread, and
10
- // the expiry is recorded in its mailbox (`expired/<id>.json`, read by `supercode message expired`);
10
+ // the expiry is recorded (read by `supercode message expired`);
11
11
  // - mail for a machine that is offline is not failing: the machine is probed once per LAST_RETRY_MS (its wait kept once
12
- // per machine, `machines/<machine>.json`, read by `supercode message waiting`), all its mail is carried when it
12
+ // per machine, read by `supercode message waiting`), all its mail is carried when it
13
13
  // answers, and the mail expires only past OFFLINE_CEILING_MS;
14
14
  // - a hand-over is waited for at most HANDOVER_BOUND_MS, and one mailbox's attempt never waits on another's;
15
- // - each attempt is a line of `carrier.jsonl`.
16
- import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
17
- import { join } from 'node:path';
15
+ // - each attempt is a line of the carrier's record.
18
16
  import { render } from './envelope.mjs';
19
17
 
20
18
  const FIRST_RETRY_MS = 2_000;
@@ -28,9 +26,7 @@ const OFFLINE_CEILING_MS = 30 * 24 * 60 * 60_000;
28
26
  // mailbox's wakes, and only for this long
29
27
  const HANDOVER_BOUND_MS = 150_000;
30
28
 
31
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
32
29
  const offline = (text) => String(text).includes('machine_offline');
33
- const safeMachine = (machine) => String(machine).replace(/[^A-Za-z0-9._-]/g, '_');
34
30
 
35
31
  export class MailCarrier {
36
32
  /**
@@ -39,64 +35,59 @@ export class MailCarrier {
39
35
  * `typeTurn(pane, harness, text, ids)` a wake typed into a hooked session's pane; `askMachine(machine, request)` one
40
36
  * request to another machine's mail door.
41
37
  */
42
- constructor({ machine, mailRoot, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
43
- Object.assign(this, { machine, mailRoot, store, live, handOver, typeTurn, askMachine, log });
44
- this.timers = new Map(); // `${folder}\0${id}` → timer of a wake here, due
45
- this.running = new Set(); // mailbox folders with an attempt in flight
38
+ constructor({ machine, store, live, handOver, typeTurn, askMachine, log = () => {} }) {
39
+ Object.assign(this, { machine, store, live, handOver, typeTurn, askMachine, log });
40
+ this.stopped = false;
41
+ this.timers = new Map(); // `${address}\0${id}` → timer of a wake here, due
42
+ this.running = new Set(); // mailboxes (addresses) with an attempt in flight
46
43
  this.machines = new Map(); // machine → timer of its next carry
47
44
  this.carrying = new Set(); // machines with a carry in flight
48
- this.remote = new Map(); // machine → Map(`${folder}\0${id}` → { folder, address, id }): wakes for another machine
45
+ this.remote = new Map(); // machine → Map(`${address}\0${id}` → { address, id }): wakes for another machine
49
46
  }
50
47
 
51
- /** Follow every wake: those filed before this daemon (one read of the mail folder), each as the store names it (its
52
- * file's event, or a mailbox the store read whole), and every wake again once per LAST_RETRY_MS, so a wake no event
53
- * named is carried within that bound. */
48
+ /** Follow every wake: those filed before this daemon (every wake the store holds), each as the store names it (the
49
+ * write that made it), and every wake again once per LAST_RETRY_MS, so a wake no event named is carried within that
50
+ * bound. */
54
51
  start() {
55
- this.store.on('wake', ({ folder, id }) => this.#wake(folder, id));
52
+ this.onWake = ({ address, id }) => this.#wake(address, id);
53
+ this.store.on('wake', this.onWake);
56
54
  this.#sweep();
57
55
  this.sweeper = setInterval(() => this.#sweep(), LAST_RETRY_MS);
58
56
  this.sweeper.unref?.();
59
57
  return this;
60
58
  }
61
59
 
62
- /** Every mailbox's wakes read from its folder and followed (one already due or scheduled is not scheduled twice). */
63
- #sweep() {
64
- let folders = [];
65
- try { folders = readdirSync(this.mailRoot); } catch { return; }
66
- for (const folder of folders) this.#rewake(folder);
67
- }
68
-
69
- stop() { clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
60
+ /** Every wake followed (one already due or scheduled is not scheduled twice). */
61
+ #sweep() { for (const { address, id } of this.store.allWakes()) this.#wake(address, id); }
70
62
 
71
- #address(folder) { try { return readFileSync(join(this.mailRoot, folder, 'address'), 'utf8').trim(); } catch { return null; } }
63
+ stop() { this.stopped = true; this.store.off('wake', this.onWake); clearInterval(this.sweeper); for (const timer of [...this.timers.values(), ...this.machines.values()]) clearTimeout(timer); this.timers.clear(); this.machines.clear(); }
72
64
 
73
- /** A wake seen (its file written or changed): attempted when it is due. */
74
- #wake(folder, id) {
75
- if (!/^[A-Za-z0-9_-]+$/.test(id)) return;
76
- const address = this.#address(folder);
77
- if (!address) return;
65
+ /** A wake seen (written or changed): attempted when it is due. */
66
+ #wake(address, id) {
67
+ if (this.stopped || !/^[A-Za-z0-9_-]+$/.test(id) || !address) return;
78
68
  const machine = address.split(':')[1];
79
69
  if (machine !== this.machine) {
80
70
  if (!this.remote.has(machine)) this.remote.set(machine, new Map());
81
- this.remote.get(machine).set(`${folder}\0${id}`, { folder, address, id });
71
+ this.remote.get(machine).set(`${address}\0${id}`, { address, id });
82
72
  this.#scheduleMachine(machine);
83
73
  return;
84
74
  }
85
- const key = `${folder}\0${id}`;
75
+ const key = `${address}\0${id}`;
86
76
  if (this.timers.has(key)) return;
87
77
  const state = this.store.wakeState(address, id);
88
- const timer = setTimeout(() => { this.timers.delete(key); void this.#local(folder, address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
78
+ const timer = setTimeout(() => { this.timers.delete(key); void this.#local(address); }, Math.max(0, (state.next_at_ms ?? 0) - Date.now()));
89
79
  timer.unref?.();
90
80
  this.timers.set(key, timer);
91
81
  }
92
82
 
93
83
  /** The wakes of one mailbox here that are due, attempted (mail_watch.rs `tick`, its local half). */
94
- async #local(folder, address) {
95
- if (this.running.has(folder)) { setTimeout(() => this.#rewake(folder), 1000).unref?.(); return; }
96
- this.running.add(folder);
84
+ async #local(address) {
85
+ if (this.stopped) return;
86
+ if (this.running.has(address)) { setTimeout(() => this.#rewake(address), 1000).unref?.(); return; }
87
+ this.running.add(address);
97
88
  try {
98
89
  const now = Date.now();
99
- const due = this.#pending(folder, address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
90
+ const due = this.#pending(address).filter((id) => (this.store.wakeState(address, id).next_at_ms ?? 0) <= now);
100
91
  if (!due.length) return;
101
92
  // a session in a daemon pane whose mail is typed there: a hooked one (its hook shows it the rest), and one with no
102
93
  // hook and no relay (Grok), whose mail that found its composer busy waits here until it is free
@@ -104,7 +95,7 @@ export class MailCarrier {
104
95
  if ((session?.door === 'hook' || session?.door === 'pane') && session.pane) {
105
96
  for (const id of due) {
106
97
  const stored = this.store.findIn(address, id);
107
- const envelope = stored ? readJson(stored.path) : null;
98
+ const envelope = stored ? this.store.body(stored) : null;
108
99
  let carried;
109
100
  if (!envelope) carried = { kind: 'filed' };
110
101
  else {
@@ -114,8 +105,9 @@ export class MailCarrier {
114
105
  carried = typed?.delivered ? { kind: 'delivered' } : typed?.queued ? { kind: 'filed' } : { kind: 'later', reason: 'its pane was not at its prompt' };
115
106
  } catch (error) { carried = { kind: 'later', reason: error.message }; }
116
107
  }
108
+ if (this.stopped) return;
117
109
  this.#record({ to: address, id, path: `${session.door}-pane`, carried });
118
- this.#settle(folder, address, id, carried);
110
+ this.#settle(address, id, carried);
119
111
  }
120
112
  return;
121
113
  }
@@ -135,41 +127,34 @@ export class MailCarrier {
135
127
  carried = answer?.outcome === 'delivered' ? { kind: 'delivered' } : answer?.outcome === 'filed' ? { kind: 'filed' } : { kind: 'later', reason: answer?.detail ?? 'not handed over' };
136
128
  } catch (error) { carried = { kind: 'later', reason: `this machine's daemon did not hand it over: ${error.message}` }; }
137
129
  }
130
+ if (this.stopped) return;
138
131
  this.#record({ to: address, id, path: 'door', carried });
139
- this.#settle(folder, address, id, carried);
132
+ this.#settle(address, id, carried);
140
133
  }
141
134
  } finally {
142
- this.running.delete(folder);
143
- this.#rewake(folder);
135
+ this.running.delete(address);
136
+ this.#rewake(address);
144
137
  }
145
138
  }
146
139
 
147
140
  /** A mailbox's wakes followed again (each one scheduled at its own due time). */
148
- #rewake(folder) {
149
- let ids = [];
150
- try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return; }
151
- for (const id of ids) this.#wake(folder, id);
152
- }
141
+ #rewake(address) { if (this.stopped) return; for (const id of this.store.wakes(address)) this.#wake(address, id); }
153
142
 
154
143
  /** A mailbox's wakes whose message is still filed; a wake whose message is gone is let go (mailbox.rs `pending_wakes`). */
155
- #pending(folder, address) {
156
- let ids = [];
157
- try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { return []; }
158
- return ids.filter((id) => {
144
+ #pending(address) {
145
+ return this.store.wakes(address).filter((id) => {
159
146
  if (this.store.findIn(address, id)) return true;
160
- try { rmSync(join(this.mailRoot, folder, 'wake', id), { force: true }); } catch { /* gone */ }
147
+ this.store.removeWake(address, id);
161
148
  return false;
162
149
  });
163
150
  }
164
151
 
165
152
  // ---- another machine's mail ------------------------------------------------------------------------------------
166
153
 
167
- #machineWaitPath(machine) { return join(this.mailRoot, 'machines', `${safeMachine(machine)}.json`); }
168
-
169
154
  /** A machine's mail carried at its next due time: its wait's next probe when it is offline, else its earliest wake. */
170
155
  #scheduleMachine(machine) {
171
- if (this.machines.has(machine) || this.carrying.has(machine)) return;
172
- const wait = readJson(this.#machineWaitPath(machine));
156
+ if (this.stopped || this.machines.has(machine) || this.carrying.has(machine)) return;
157
+ const wait = this.store.machineWait(machine);
173
158
  let at = wait?.next_probe_ms ?? null;
174
159
  if (at == null) {
175
160
  at = Infinity;
@@ -182,15 +167,14 @@ export class MailCarrier {
182
167
  }
183
168
 
184
169
  /** Every wake filed here for `machine` (as their events named them), with when its message was filed, oldest first; a
185
- * wake whose file or message is gone is let go. */
170
+ * wake whose message is gone, or that is gone itself, is let go. */
186
171
  #remoteWakes(machine) {
187
172
  const items = [];
188
173
  for (const [key, item] of this.remote.get(machine) ?? []) {
189
- let present = true;
190
- try { readFileSync(join(this.mailRoot, item.folder, 'wake', item.id)); } catch { present = false; }
174
+ const present = this.store.hasWake(item.address, item.id);
191
175
  const found = present ? this.store.findIn(item.address, item.id) : null;
192
- if (!found) { this.remote.get(machine).delete(key); if (present) { try { rmSync(join(this.mailRoot, item.folder, 'wake', item.id), { force: true }); } catch { /* gone */ } } continue; }
193
- const envelope = readJson(found.path);
176
+ if (!found) { this.remote.get(machine).delete(key); if (present) this.store.removeWake(item.address, item.id); continue; }
177
+ const envelope = this.store.body(found);
194
178
  items.push({ ...item, filed: Number(envelope?.created_at_ms) || 0 });
195
179
  }
196
180
  return items.sort((a, b) => a.filed - b.filed);
@@ -199,17 +183,18 @@ export class MailCarrier {
199
183
  /** One machine's mail carried (mail_watch.rs `tick`, its remote half): its oldest due message probes the machine, and
200
184
  * when it answers, the rest follow in this pass. */
201
185
  async #carry(machine) {
186
+ if (this.stopped) return;
202
187
  this.carrying.add(machine);
203
188
  try {
204
189
  const now = Date.now();
205
190
  const items = this.#remoteWakes(machine);
206
191
  if (!items.length) return;
207
- const wait = readJson(this.#machineWaitPath(machine));
192
+ const wait = this.store.machineWait(machine);
208
193
  if (wait) {
209
194
  // a machine away longer than the ceiling is not coming back for this mail: it expires, saying so
210
195
  if (now - (wait.offline_since_ms ?? now) >= OFFLINE_CEILING_MS) {
211
196
  const reason = `${machine} has been offline for ${OFFLINE_CEILING_MS / 86_400_000} days (since epoch ms ${wait.offline_since_ms}): ${wait.last_error ?? 'not linked'}`;
212
- for (const item of items) this.#expire(item.folder, item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
197
+ for (const item of items) this.#expire(item.address, item.id, this.store.wakeState(item.address, item.id).attempts ?? 0, reason);
213
198
  this.#setMachineWait(machine, null);
214
199
  this.#line({ t: now, machine, outcome: 'expired', messages: items.length, reason });
215
200
  return;
@@ -225,11 +210,12 @@ export class MailCarrier {
225
210
  if (!found || (found.state === 'read' && this.store.claimOf(item.address, item.id)?.recipient === true)) carried = { kind: 'filed' };
226
211
  else {
227
212
  called = true;
228
- carried = await this.#carryOne(machine, item.address, readJson(found.path));
213
+ carried = await this.#carryOne(machine, item.address, this.store.body(found));
229
214
  }
215
+ if (this.stopped) return;
230
216
  if (carried.kind === 'offline') { offlineAnswer = carried.reason; break; }
231
217
  carriedHere++;
232
- this.#settle(item.folder, item.address, item.id, carried);
218
+ this.#settle(item.address, item.id, carried);
233
219
  }
234
220
  if (called) this.#line({ t: now, machine, outcome: offlineAnswer ? 'offline' : 'carried', waiting: items.length, carried: carriedHere, reason: offlineAnswer });
235
221
  if (offlineAnswer) this.#setMachineWait(machine, { machine, offline_since_ms: wait?.offline_since_ms ?? now, next_probe_ms: now + LAST_RETRY_MS, probes: (wait?.probes ?? 0) + 1, last_error: offlineAnswer });
@@ -256,10 +242,7 @@ export class MailCarrier {
256
242
  }
257
243
 
258
244
  #setMachineWait(machine, wait) {
259
- const path = this.#machineWaitPath(machine);
260
- if (!wait) { try { rmSync(path, { force: true }); } catch { /* gone */ } return; }
261
- try { mkdirSync(join(this.mailRoot, 'machines'), { recursive: true }); writeFileSync(`${path}.tmp`, JSON.stringify(wait)); renameSync(`${path}.tmp`, path); }
262
- catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
245
+ try { this.store.setMachineWait(machine, wait); } catch (error) { this.log(`carrier: ${machine}'s wait was not kept: ${error.message}`); }
263
246
  }
264
247
 
265
248
  // ---- what waits, and what expired (`message waiting`, `message expired`) -----------------------------------------
@@ -268,21 +251,16 @@ export class MailCarrier {
268
251
  * filed first (mailbox.rs `waiting_mail`, field for field). */
269
252
  waiting() {
270
253
  const all = [];
271
- let folders = [];
272
- try { folders = readdirSync(this.mailRoot); } catch { return all; }
273
- for (const folder of folders) {
274
- let ids = [];
275
- try { ids = readdirSync(join(this.mailRoot, folder, 'wake')); } catch { continue; }
276
- if (!ids.length) continue;
277
- const address = this.#address(folder);
278
- if (!address) continue;
254
+ const waits = new Map();
255
+ for (const { address, id } of this.store.allWakes()) {
279
256
  const machine = address.split(':')[1];
280
- const wait = machine !== this.machine ? readJson(this.#machineWaitPath(machine)) : null;
281
- for (const id of ids) {
257
+ if (machine !== this.machine && !waits.has(machine)) waits.set(machine, this.store.machineWait(machine));
258
+ const wait = machine !== this.machine ? waits.get(machine) : null;
259
+ {
282
260
  const state = this.store.wakeState(address, id);
283
261
  if (!wait && !(state.attempts > 0) && state.last_error == null) continue;
284
262
  const found = this.store.findIn(address, id);
285
- const envelope = found ? readJson(found.path) : null;
263
+ const envelope = found ? this.store.body(found) : null;
286
264
  if (!envelope) continue;
287
265
  all.push({ id, to: address, from: envelope.from ?? '', subject: envelope.subject ?? null, filed_at_ms: Number(envelope.created_at_ms) || 0,
288
266
  waiting_for: wait ? wait.machine : null, since_ms: wait?.offline_since_ms ?? state.failing_since_ms ?? (Number(envelope.created_at_ms) || 0),
@@ -293,23 +271,12 @@ export class MailCarrier {
293
271
  }
294
272
 
295
273
  /** Every wake that expired here, oldest first (mailbox.rs `expired_wakes`). */
296
- expired() {
297
- const all = [];
298
- let folders = [];
299
- try { folders = readdirSync(this.mailRoot); } catch { return all; }
300
- for (const folder of folders) {
301
- let names = [];
302
- try { names = readdirSync(join(this.mailRoot, folder, 'expired')); } catch { continue; }
303
- for (const name of names) { const expiry = readJson(join(this.mailRoot, folder, 'expired', name)); if (expiry?.id) all.push(expiry); }
304
- }
305
- return all.sort((a, b) => (a.expired_at_ms ?? 0) - (b.expired_at_ms ?? 0));
306
- }
274
+ expired() { return this.store.expired(); }
307
275
 
308
276
  // ---- how an attempt went ---------------------------------------------------------------------------------------
309
277
 
310
278
  /** Record how a wake went: done, waiting for its next attempt, or expired at its bound (mail_watch.rs `settle`). */
311
- #settle(folder, address, id, carried) {
312
- const wakePath = join(this.mailRoot, folder, 'wake', id);
279
+ #settle(address, id, carried) {
313
280
  if (carried.kind === 'delivered' || carried.kind === 'filed') {
314
281
  if (carried.kind === 'delivered') {
315
282
  // the carrier's own hand-off record: delivered here by its door, or carried to its machine
@@ -317,7 +284,7 @@ export class MailCarrier {
317
284
  const found = this.store.findIn(address, id);
318
285
  if (found && found.state !== 'read') { try { this.store.markRead(address, found.path); } catch { /* read already */ } }
319
286
  }
320
- try { rmSync(wakePath, { force: true }); } catch { /* gone */ }
287
+ this.store.removeWake(address, id);
321
288
  return;
322
289
  }
323
290
  if (carried.kind === 'offline') return;
@@ -326,29 +293,23 @@ export class MailCarrier {
326
293
  const attempts = (state.attempts ?? 0) + 1;
327
294
  const since = state.failing_since_ms ?? now;
328
295
  const lifetime = address.split(':')[1] === this.machine ? LOCAL_WAKE_LIFETIME_MS : REMOTE_WAKE_LIFETIME_MS;
329
- if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(folder, address, id, attempts, carried.reason); return; }
296
+ if (attempts >= WAKE_ATTEMPTS && now - since >= lifetime) { this.#expire(address, id, attempts, carried.reason); return; }
330
297
  const wait = Math.min(LAST_RETRY_MS, FIRST_RETRY_MS * 2 ** Math.min(attempts - 1, 16));
331
298
  this.store.setWakeState(address, id, { ...state, attempts, failing_since_ms: since, next_at_ms: now + wait, last_error: carried.reason });
332
299
  }
333
300
 
334
- /** End a wake undelivered: the message stays filed and unread, and the expiry is recorded in its mailbox. */
335
- #expire(folder, address, id, attempts, reason) {
301
+ /** End a wake undelivered: the message stays filed and unread, and the expiry is recorded. */
302
+ #expire(address, id, attempts, reason) {
336
303
  const found = this.store.findIn(address, id);
337
- const envelope = found ? readJson(found.path) : null;
304
+ const envelope = found ? this.store.body(found) : null;
338
305
  const expiry = { id, to: address, from: envelope?.from ?? '', subject: envelope?.subject ?? null, filed_at_ms: Number(envelope?.created_at_ms) || 0, expired_at_ms: Date.now(), attempts, reason };
339
- try {
340
- mkdirSync(join(this.mailRoot, folder, 'expired'), { recursive: true });
341
- const temporary = join(this.mailRoot, folder, 'tmp', `expired.${id}`);
342
- writeFileSync(temporary, JSON.stringify(expiry));
343
- renameSync(temporary, join(this.mailRoot, folder, 'expired', `${id}.json`));
344
- rmSync(join(this.mailRoot, folder, 'wake', id), { force: true });
345
- } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
306
+ try { this.store.expire(address, id, expiry); } catch (error) { this.log(`carrier: the expiry of ${id} was not recorded: ${error.message}`); }
346
307
  }
347
308
 
348
- /** One local delivery attempt in the carrier's record (`carrier.jsonl`). */
309
+ /** One local delivery attempt in the carrier's record. */
349
310
  #record({ to, id, path, carried }) {
350
311
  this.#line({ t: Date.now(), to, id, path, outcome: carried.kind, reason: carried.reason ?? null });
351
312
  }
352
313
 
353
- #line(line) { try { appendFileSync(join(this.mailRoot, 'carrier.jsonl'), `${JSON.stringify(line)}\n`); } catch { /* the record is best kept */ } }
314
+ #line(line) { this.store.carrierLine(line); }
354
315
  }