@volter/supercode-teams 0.3.150 → 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/send.mjs CHANGED
@@ -3,13 +3,12 @@
3
3
  // came through another machine's link to this machine's mail door. It resolves the receiver on the live-session map,
4
4
  // routes to another machine through Teams or to the receiver's door here, guards against loops and repeats, and words
5
5
  // the outcome for the sending agent, as crates/harness/src/mail_send.rs and mail_route.rs `deliver` did (their texts
6
- // kept word for word: an agent reads them). The mail it files is mailbox.rs's, through the store (store.mjs).
7
- import { appendFileSync, chmodSync, closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
8
- import { join } from 'node:path';
6
+ // kept word for word: an agent reads them). Mail is filed through the daemon's SQLite store.
9
7
  import { blake3Hex, render, shortId } from './envelope.mjs';
10
8
  import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
11
9
  import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
12
10
  import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
11
+ import { nativeMailContinuation } from './continuation.mjs';
13
12
 
14
13
  export const EXIT_REFUSED = 2;
15
14
  export const EXIT_UNKNOWN = 3;
@@ -43,7 +42,6 @@ const outcome = (code, text, receipt = null) => ({ code, text, receipt });
43
42
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
44
43
  /** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
45
44
  const paneOf = (session) => { const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
46
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
47
45
 
48
46
  /** A delivery a door refused before anything was sent. */
49
47
  class Refused extends Error { constructor(kind, bytes = 0) { super(kind); this.kind = kind; this.bytes = bytes; } }
@@ -56,10 +54,20 @@ export class MailSend {
56
54
  * question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
57
55
  * pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
58
56
  */
59
- constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
60
- Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
57
+ constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects = () => [], log = () => {} }) {
58
+ Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects, log });
61
59
  this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
62
60
  this.relaying = new Set(); // `<address>\n<id>` of each relay send running here (route): its wake waits for it
61
+ for (const name of ['askMachine', 'deliverRuntime', 'codexAnswer', 'typeTurn']) {
62
+ const operation = this[name];
63
+ this[name] = (...args) => { this.#active(); return operation(...args); };
64
+ }
65
+ }
66
+
67
+ stop() { this.stopped = true; }
68
+
69
+ #active() {
70
+ if (this.stopped || this.store.database?.closed) throw Object.assign(new Error('the daemon is stopping; no mail delivery was submitted'), { code: 503, notAsked: true });
63
71
  }
64
72
 
65
73
  // ---- one send ----------------------------------------------------------------------------------------------------
@@ -70,6 +78,18 @@ export class MailSend {
70
78
  * came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
71
79
  */
72
80
  async send(caller, to, body, options = {}, context = {}) {
81
+ this.#active();
82
+ if (context.link && this.#remoteMachine(to)) return outcome(EXIT_UNKNOWN, `${to} is not on this machine; a send through its mail door is for its own sessions.`);
83
+ // Every stream earns its current route's grants before it can join the existing same-ID send in flight.
84
+ let receiver = null;
85
+ if (!this.#remoteMachine(to)) {
86
+ receiver = await this.#receiver(to, this.live.sessions());
87
+ if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); receiver = await this.#receiver(to, this.live.sessions()); }
88
+ if (receiver.outcome) return receiver.outcome;
89
+ const refused = await this.#authorizeReceiver(context, receiver.continuation?.chain ?? [receiver.address]);
90
+ if (refused) return refused;
91
+ }
92
+ this.#active();
73
93
  // A send under an --id is held while it runs: the sent marker is written only once it is done, so a second send
74
94
  // under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
75
95
  // after a first that was not sent sends.
@@ -78,17 +98,25 @@ export class MailSend {
78
98
  const running = (this.inFlight ??= new Map()).get(key);
79
99
  if (running) {
80
100
  const first = await running.catch(() => null);
81
- if (first?.code === 0) return outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null);
101
+ if (first?.code === 0) {
102
+ const addresses = [...(first.continuation?.chain ?? []), ...(first.receipt?.recipients ?? []), first.receipt?.recipient].filter(Boolean);
103
+ if (addresses.length) {
104
+ const refused = await this.#authorizeReceiver(context, addresses);
105
+ if (refused) return refused;
106
+ } else if (context.authorizeRecipients) return outcome(EXIT_REFUSED, 'Not sent: the earlier send has no recipient receipt to authorize on this stream. Nothing was sent again.');
107
+ return { ...outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null), ...(first.continuation ? { continuation: first.continuation } : {}) };
108
+ }
82
109
  return this.send(caller, to, body, options, context);
83
110
  }
84
111
  }
85
- const sending = this.#send(caller, to, body, options, context);
112
+ const sending = this.#send(caller, to, body, options, context, receiver);
86
113
  if (!key) return sending;
87
114
  this.inFlight.set(key, sending);
88
115
  try { return await sending; } finally { if (this.inFlight.get(key) === sending) this.inFlight.delete(key); }
89
116
  }
90
117
 
91
- async #send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {}, { link = null } = {}) {
118
+ async #send(caller, to, body, { subject = null, in_reply_to = null, notify_when_idle = false, queue = false, id = null, notice = false, thread = null, message_id = null } = {}, context = {}, receiver = null) {
119
+ const { link = null } = context;
92
120
  // an id its client chose (so a lost answer can name it), never an idempotency key: it marks nothing sent
93
121
  const chosen = typeof message_id === 'string' && /^m-[0-9a-f]{24}$/.test(message_id) ? message_id : null;
94
122
  const machine = this.#remoteMachine(to);
@@ -103,10 +131,26 @@ export class MailSend {
103
131
  const request = { op: 'send', from: caller.address, from_name: caller.name, to, body, subject, in_reply_to: answered.id, thread: carried, notify_when_idle, queue, id: messageId, notice };
104
132
  const sent = await this.#remoteSend(machine, request);
105
133
  // unanswered, it may have been delivered: kept in the send log, so a later reply to it resolves here
106
- if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId);
134
+ if (sent.code === 0 || sent.code === EXIT_STORED || sent.receipt?.unanswered === true) this.#recordSend(caller.address, to, messageId, carried ?? answered.id ?? messageId, sent.continuation);
107
135
  return sent;
108
136
  }
109
- return this.#deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link);
137
+ const answer = await this.#deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread, chosen }, link, context);
138
+ return this.#annotateReceiver(answer, receiver);
139
+ }
140
+
141
+ #annotateReceiver(answer, receiver) {
142
+ const continuation = receiver.continuation;
143
+ const alias = continuation && !answer.text.includes(receiver.alias) ? receiver.alias : '';
144
+ return { ...answer, text: answer.text + alias, ...(continuation ? { continuation } : {}),
145
+ receipt: answer.receipt ? { ...answer.receipt, recipient: receiver.address,
146
+ recipients: answer.receipt.recipients ?? [receiver.address], ...(continuation ? { continuation } : {}) } : null };
147
+ }
148
+
149
+ async #authorizeReceiver(context, addresses) {
150
+ if (!context.authorizeRecipients) return null;
151
+ const reason = await context.authorizeRecipients(addresses);
152
+ this.#active();
153
+ return reason ? outcome(EXIT_REFUSED, `Not sent: ${reason}. Nothing was filed or delivered by this request.`) : null;
110
154
  }
111
155
 
112
156
  /** The machine `to` names when it is not this one, in its normal form. */
@@ -123,7 +167,7 @@ export class MailSend {
123
167
  const answer = await this.askMachine(machine, request);
124
168
  const code = Number.isInteger(answer?.code) ? answer.code : EXIT_FAILED;
125
169
  const text = typeof answer?.text === 'string' ? answer.text : typeof answer?.detail === 'string' ? answer.detail : 'the other machine answered nothing readable';
126
- return outcome(code, text, answer?.receipt ?? null);
170
+ return { ...outcome(code, text, answer?.receipt ?? null), ...(answer?.continuation ? { continuation: answer.continuation } : {}) };
127
171
  } catch (error) {
128
172
  const detail = String(error?.message ?? error);
129
173
  // the door took it and its answer was lost: unanswered, never refused or failed (what it did there is unknown)
@@ -140,14 +184,14 @@ export class MailSend {
140
184
  #answeredId(callerAddress, answered) {
141
185
  if (typeof answered !== 'string' || answered.length >= FULL_ID_LEN) return { id: answered ?? null };
142
186
  const found = this.store.findPrefixIn(callerAddress, answered).map((item) => item.envelope.id);
143
- for (const id of this.#sentIds(callerAddress)) if (id.startsWith(answered) && !found.includes(id)) found.push(id);
187
+ for (const id of this.store.sentIds(callerAddress, answered)) if (!found.includes(id)) found.push(id);
144
188
  if (found.length === 1) return { id: found[0] };
145
189
  if (!found.length) return { refused: outcome(EXIT_REFUSED, `Not sent: no message you received or sent has an id starting ${answered}. Name the message you answer as its envelope or your send showed it.`) };
146
190
  return { refused: outcome(EXIT_REFUSED, `Not sent: ${answered} names ${found.length} messages you received or sent (${found.join(', ')}). Give more of its id.`) };
147
191
  }
148
192
 
149
193
  /** The receiver `to` names here: `{ address, name, session (its live row, when it runs) }`, or an outcome. */
150
- #receiver(to, sessions) {
194
+ async #receiver(to, sessions) {
151
195
  const operator = String(to).startsWith('operator:') ? makeAddress(this.machine, 'operator', String(to).slice('operator:'.length)) : null;
152
196
  const parsed = parseAddress(operator ?? to);
153
197
  // an operator (a board, a script) is not a listed session: its address is taken as given
@@ -158,7 +202,9 @@ export class MailSend {
158
202
  return { address: parsed.address, name: `agent ${parsed.session}@${parsed.machine}` };
159
203
  }
160
204
  if (parsed) {
161
- const session = sessions.find((row) => row.address === parsed.address);
205
+ const resolved = await this.#nativeReceiver(parsed.address, sessions);
206
+ if (resolved) return resolved;
207
+ const session = this.live.sessions().find((row) => row.address === parsed.address);
162
208
  if (!session) return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(to) ?? `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
163
209
  return { address: session.address, name: session.name, session };
164
210
  }
@@ -172,7 +218,9 @@ export class MailSend {
172
218
  if (!matching.length) {
173
219
  const remembered = this.#rememberedAddress(wanted);
174
220
  if (remembered) {
175
- const session = sessions.find((row) => row.address === remembered);
221
+ const resolved = await this.#nativeReceiver(remembered, sessions);
222
+ if (resolved) return resolved;
223
+ const session = this.live.sessions().find((row) => row.address === remembered);
176
224
  if (!session) return { outcome: outcome(EXIT_UNKNOWN, `${wanted} was ${remembered}, which is not running. Nothing was sent. Run supercode message list for the live sessions.`) };
177
225
  return { address: session.address, name: session.name, session, alias: ` "${wanted}" is a name it ran under before; it runs as ${session.name} now.` };
178
226
  }
@@ -190,6 +238,18 @@ export class MailSend {
190
238
  return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
191
239
  }
192
240
 
241
+ async #nativeReceiver(address, sessions) {
242
+ const live = sessions.find((row) => row.address === address);
243
+ if (live) return { address: live.address, name: live.name, session: live };
244
+ const continued = await nativeMailContinuation(address, { machine: this.machine, claudeProjects: this.claudeProjects, sessions: () => this.live.sessions() });
245
+ if (continued?.error) return { outcome: outcome(EXIT_UNKNOWN, `Not sent: ${address}'s continuation is unresolved: ${continued.error}. Nothing was sent.`) };
246
+ if (!continued) return null;
247
+ const { session, addressed, chain } = continued;
248
+ return { address: session.address, name: session.name, session,
249
+ continuation: { addressed, recipient: session.address, chain },
250
+ alias: ` Addressed to ${address}; its native continued-in record routes it to ${session.address}.` };
251
+ }
252
+
193
253
  /** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
194
254
  * read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
195
255
  async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
@@ -219,19 +279,14 @@ export class MailSend {
219
279
  }
220
280
 
221
281
  /** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
222
- async #deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link) {
223
- let sessions = this.live.sessions();
224
- let receiver = this.#receiver(to, sessions);
225
- // only a receiver not found waits for the Codex sessions to be known (one the map holds is reached at once)
226
- if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); sessions = this.live.sessions(); receiver = this.#receiver(to, sessions); }
227
- if (receiver.outcome) return receiver.outcome;
282
+ async #deliver(caller, receiver, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link, context) {
283
+ const sessions = this.live.sessions();
228
284
  const { address, name } = receiver;
229
285
  if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
230
286
  const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
231
287
  const messageId = id == null && chosen ? chosen : messageIdFor(caller.address, id);
232
- const marker = this.#sentMarker(caller.address, messageId);
233
288
  const terminal = door?.kind === 'hook' && door.pane != null;
234
- let previous = null; try { previous = readFileSync(marker, 'utf8'); } catch { /* not sent before */ }
289
+ const previous = this.store.sentMarker(caller.address, messageId);
235
290
  if (previous !== null) {
236
291
  if (terminal && !queue) this.store.requestWake(address, messageId);
237
292
  return outcome(0, `Already sent with --id ${id ?? ''}: ${previous} Nothing was sent again.`, this.#deliveryReceipt(address, messageId, terminal));
@@ -250,7 +305,14 @@ export class MailSend {
250
305
  });
251
306
  const plan = await this.#plan(sent, address, {});
252
307
  if (plan.outcome) return plan.outcome;
253
- if (plan.plan) return this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle });
308
+ const recipients = plan.plan ? [...new Set([address, ...(plan.plan.agent ? [plan.plan.agent] : []),
309
+ ...plan.plan.recipients.filter(item => item.address !== caller.address).map(item => item.address)])] : [address];
310
+ const refused = await this.#authorizeReceiver(context, [...(receiver.continuation?.chain ?? []), ...recipients]);
311
+ if (refused) return refused;
312
+ if (plan.plan) {
313
+ const answer = await this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle, continuation: receiver.continuation });
314
+ return { ...answer, receipt: answer.receipt ? { ...answer.receipt, recipients } : null };
315
+ }
254
316
  // the thread this message is in, kept in the send log: a later reply to it continues that thread
255
317
  const sentIn = threadOf(sent);
256
318
  if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, this.#notYetKnown(name) ?? `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
@@ -263,7 +325,7 @@ export class MailSend {
263
325
  if (error instanceof Refused && error.kind === 'queue') return outcome(EXIT_REFUSED, `Not sent: ${name} is idle, and a Claude session always starts a turn when a message arrives, so --queue cannot hold it. Send without --queue to wake it.`);
264
326
  if (error instanceof Refused) return outcome(EXIT_REFUSED, `Not sent: the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}. Write it to a file and send its path instead.`);
265
327
  // may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
266
- if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
328
+ if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
267
329
  return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
268
330
  }
269
331
  const [code, what] = {
@@ -281,12 +343,12 @@ export class MailSend {
281
343
  pane_waits: [EXIT_STORED, 'Stored (not failed): its pane\'s composer was not empty, so the message waits in its mailbox; it sees it with supercode message inbox. Don\'t resend'],
282
344
  stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
283
345
  }[delivered];
284
- this.#recordSend(caller.address, address, messageId, sentIn);
346
+ this.#recordSend(caller.address, address, messageId, sentIn, receiver.continuation);
285
347
  const harness = parseAddress(address).harness;
286
348
  const text = code === 0
287
349
  ? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
288
350
  : `${name} (${harness}): ${what}. Message id ${shortId(messageId)}.${idleNote}`;
289
- if (code === 0 && id != null) { try { mkdirSync(join(marker, '..'), { recursive: true }); writeFileSync(marker, text); } catch { /* the marker is best kept */ } }
351
+ if (code === 0 && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
290
352
  return outcome(code, text, code === 0 ? this.#deliveryReceipt(address, messageId, terminal) : null);
291
353
  }
292
354
 
@@ -305,6 +367,7 @@ export class MailSend {
305
367
  * refused before anything was sent, and an Error with what failed.
306
368
  */
307
369
  async route(sent, to, door, wake, notifyWhenIdle) {
370
+ this.#active();
308
371
  this.store.open(to);
309
372
  let finalReply = false;
310
373
  let delivered;
@@ -368,7 +431,7 @@ export class MailSend {
368
431
  if (filedHere && wake) { try { this.store.requestWake(to, handed.id); } catch { /* the wake filed above stands */ } }
369
432
  throw Object.assign(new Error(relayed.detail), { unanswered: true });
370
433
  }
371
- if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
434
+ if (!relayed.delivered && !received) { if (filedHere) { try { this.store.remove(to, filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
372
435
  // relayed: a copy filed unread before this delivery (an earlier unanswered or queued one, held for the receiver's
373
436
  // own read) is moved to cur/, so that read never hands it over a second time
374
437
  if (relayed.delivered && !received) {
@@ -408,7 +471,7 @@ export class MailSend {
408
471
  */
409
472
  async deliverPlanned(caller, sent, plan, { queue = false, notify_when_idle = false } = {}) { return this.#deliverPlanned(caller, sent, plan, { queue, notify_when_idle }); }
410
473
 
411
- async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle }) {
474
+ async #deliverPlanned(caller, sent, plan, { queue, notify_when_idle, continuation = null }) {
412
475
  const reached = [], copied = [], failed = [];
413
476
  let recorded = false;
414
477
  const held = []; // recipients whose own copy is held unread with its wake (an unanswered relay)
@@ -428,7 +491,7 @@ export class MailSend {
428
491
  const sessions = this.live.sessions();
429
492
  for (const recipient of plan.recipients.filter((item) => item.address !== caller.address)) {
430
493
  const label = this.#label(sessions, recipient.address);
431
- this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent));
494
+ this.#recordSend(caller.address, recipient.address, sent.id, threadOf(sent), continuation);
432
495
  if (!recipient.wake) {
433
496
  try { this.#fileUnread(recipient.address, sent); copied.push(label); } catch (error) { failed.push(`${label} (${error.message})`); }
434
497
  continue;
@@ -512,6 +575,7 @@ export class MailSend {
512
575
  try { await this.agents.resume(to); } catch (error) { throw new Error(`not resumed: ${error.message}`); }
513
576
  const started = Date.now();
514
577
  for (;;) {
578
+ this.#active();
515
579
  door = this.#door(to, this.live.sessions());
516
580
  if (door) break;
517
581
  if (Date.now() - started > RESUME_WAIT_MS) return { how: 'not_running', text: 'resumed; the message waits in its mailbox' };
@@ -597,7 +661,7 @@ export class MailSend {
597
661
  if (typeof key !== 'string' || !key || Buffer.byteLength(key) > 256) return refused('invalid_key', 'idempotency_key needs 1–256 bytes');
598
662
  id = `m-${blake3Hex(JSON.stringify([sender, receiver, key]))}`.slice(0, 26);
599
663
  const previous = this.store.findIn(receiver, id);
600
- const saved = previous ? readJson(previous.path) : null;
664
+ const saved = previous ? this.store.body(previous) : null;
601
665
  if (saved && (saved.body !== text || (saved.subject ?? null) !== subject || saved.kind !== (params.channel ? 'channel' : 'peer') || saved.from_name !== (params.sender_name ?? `${fromName}@${this.machine}`) || (saved.in_reply_to ?? null) !== (params.in_reply_to ?? null) || (saved.voice_for ?? null) !== (params.voice_for ?? null))) {
602
666
  return refused('idempotency_conflict', 'this key already names different mail');
603
667
  }
@@ -732,9 +796,11 @@ export class MailSend {
732
796
  * (mail_route.rs `type_user_turns`).
733
797
  */
734
798
  async typeUserTurns(address, pane) {
799
+ this.#active();
735
800
  const typed = [];
736
801
  const harness = parseAddress(address).harness;
737
802
  for (const waiting of this.store.userTurns(address)) {
803
+ this.#active();
738
804
  const claimed = this.store.claimUserTurn(address, waiting);
739
805
  if (!claimed) break;
740
806
  let result;
@@ -764,9 +830,10 @@ export class MailSend {
764
830
  * `{ outcome: 'delivered' | 'filed' | 'later', detail }`.
765
831
  */
766
832
  async handOver(address, id, { wake = false } = {}) {
833
+ this.#active();
767
834
  const found = this.store.findIn(address, id);
768
835
  if (!found) return { outcome: 'filed', detail: 'not in its mailbox' };
769
- const sent = readJson(found.path);
836
+ const sent = this.store.body(found);
770
837
  if (!sent) return { outcome: 'later', detail: 'its envelope could not be read' };
771
838
  if (found.state !== 'unread') return { outcome: 'filed', detail: 'already read' };
772
839
  // one already handed to its receiver's door (that delivery's claim, the door's or the carrier's: a daemon that ended
@@ -815,8 +882,10 @@ export class MailSend {
815
882
  return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
816
883
  } finally {
817
884
  this.handing.delete(key);
818
- const after = this.store.wakeState(address, id);
819
- this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
885
+ if (!this.stopped) {
886
+ const after = this.store.wakeState(address, id);
887
+ this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
888
+ }
820
889
  }
821
890
  }
822
891
 
@@ -824,13 +893,13 @@ export class MailSend {
824
893
 
825
894
  /** Answer the native question `id` asked in `to` (mail_question.rs `reply`): only its recorded creator answers, once. */
826
895
  async #questionReply(caller, to, id, body, link) {
827
- const dir = join(this.mailRoot, 'native-questions');
828
- const path = (suffix) => join(dir, `${id}.${suffix}`);
829
- const question = readJson(path('json'));
896
+ const key = (suffix) => `${id}.${suffix}`;
897
+ const question = this.store.record('native_questions', key('json'));
830
898
  if (!question) return outcome(EXIT_FAILED, `Not answered: question ${id} could not be read.`);
831
899
  if (question.binding?.session !== to || question.binding?.creator !== caller.address) return outcome(EXIT_REFUSED, 'Not answered: this question belongs to a different session or recipient.');
900
+ if (this.store.record('native_questions', key('claim'))) return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
832
901
  if (parseAddress(to).harness === 'claude-code') {
833
- const waiting = readJson(path('waiting'));
902
+ const waiting = this.store.record('native_questions', key('waiting'));
834
903
  if (!waiting) return outcome(EXIT_REFUSED, 'Not answered: the native question hook is no longer waiting.');
835
904
  let alive = false; try { process.kill(waiting.pid, 0); alive = true; } catch (error) { alive = error.code === 'EPERM'; }
836
905
  if (!Number.isInteger(waiting.pid) || !alive) return outcome(EXIT_REFUSED, 'Not answered: the native question hook has ended.');
@@ -841,24 +910,25 @@ export class MailSend {
841
910
  envelope: makeEnvelope({ id: `u-${blake3Hex(`${id}\n${caller.address}`).slice(0, 24)}`, from: caller.address, from_name: caller.name, sender_identity: senderIdentity(caller.address, link, this.machine), kind: 'user', reply_via: { mode: 'none' }, in_reply_to: id, thread: id, body }),
842
911
  answers,
843
912
  };
844
- try { createPrivate(dir, path('claim'), reply); }
845
- catch (error) {
846
- if (error.code === 'EEXIST') return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
847
- throw error;
848
- }
849
- this.store.deliver(to, reply.envelope);
850
- // published to the hook only once the whole reply is in mail
851
- linkSync(path('claim'), path('reply'));
913
+ const claimed = this.store.transaction(() => {
914
+ if (!this.store.putRecord('native_questions', key('claim'), reply, { create: true })) return false;
915
+ this.store.deliver(to, reply.envelope);
916
+ this.store.putRecord('native_questions', key('reply'), reply, { create: true });
917
+ return true;
918
+ });
919
+ if (!claimed) return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
852
920
  if (parseAddress(to).harness === 'claude-code') return outcome(0, `Answer ${shortId(reply.envelope.id)} recorded in reply to ${id}; awaiting confirmation from the native question hook. It is not composer input.`);
853
- const binding = readJson(join(dir, `binding-${blake3Hex(to)}.json`));
921
+ const binding = this.store.record('native_questions', `binding-${blake3Hex(to)}.json`);
854
922
  if (!binding?.socket_path) return outcome(EXIT_STORED, 'Answer recorded but NOT delivered: this launch has no native question endpoint. Nothing was typed.');
855
923
  let result;
856
924
  try { result = await this.codexAnswer({ socketPath: binding.socket_path, threadId: parseAddress(to).session, answer: { question: question.native, answers: reply.answers } }); }
857
925
  catch (error) { result = { delivered: false, reason: error.code ?? 'native-question-error' }; }
858
926
  if (result?.delivered === true) {
859
- const filed = this.store.findIn(to, reply.envelope.id);
860
- if (filed?.state === 'unread') this.store.markRead(to, filed.path);
861
- try { createPrivate(dir, path('delivered'), Date.now()); } catch (error) { if (error.code !== 'EEXIST') throw error; }
927
+ this.store.transaction(() => {
928
+ const filed = this.store.findIn(to, reply.envelope.id);
929
+ if (filed?.state === 'unread') this.store.markRead(to, filed.path);
930
+ this.store.putRecord('native_questions', key('delivered'), Date.now(), { create: true });
931
+ });
862
932
  return outcome(0, `Answer ${shortId(reply.envelope.id)} delivered to native question ${id}.`);
863
933
  }
864
934
  return outcome(EXIT_STORED, `Answer recorded but NOT confirmed by the native question: ${JSON.stringify(result?.reason ?? null)}. It will not be typed or automatically resent.`);
@@ -866,29 +936,10 @@ export class MailSend {
866
936
 
867
937
  // ---- the send log and its guards ---------------------------------------------------------------------------------
868
938
 
869
- #sentDir(sender) { return join(this.mailRoot, 'sent', blake3Hex(sender).slice(0, 24)); }
870
- #sentMarker(sender, id) { return join(this.#sentDir(sender), id); }
871
-
872
- #logOf(sender) {
873
- let text = '';
874
- try { text = readFileSync(join(this.#sentDir(sender), 'log.jsonl'), 'utf8'); } catch { return []; }
875
- return text.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
876
- }
877
-
878
- #sentIds(sender) { return this.#logOf(sender).map((entry) => entry.id).filter((id) => typeof id === 'string'); }
879
- #sentThread(sender, id) { return this.#logOf(sender).find((entry) => entry.id === id && typeof entry.thread === 'string')?.thread ?? null; }
939
+ #sentThread(sender, id) { return this.store.sentThread(sender, id); }
880
940
 
881
941
  /** The thread any author on this machine sent `id` in, as its send log keeps it. */
882
- #sentThreadHere(id) {
883
- let names = [];
884
- try { names = readdirSync(join(this.mailRoot, 'sent')); } catch { return null; }
885
- for (const name of names) {
886
- let text = '';
887
- try { text = readFileSync(join(this.mailRoot, 'sent', name, 'log.jsonl'), 'utf8'); } catch { continue; }
888
- for (const line of text.split('\n')) { let entry = null; try { entry = JSON.parse(line); } catch { continue; } if (entry?.id === id && typeof entry.thread === 'string') return entry.thread; }
889
- }
890
- return null;
891
- }
942
+ #sentThreadHere(id) { return this.store.sentThreadHere(id); }
892
943
 
893
944
  /** The thread `parent` is filed in on this machine (mailbox.rs `filed_thread`). */
894
945
  #filedThread(parent) {
@@ -906,19 +957,19 @@ export class MailSend {
906
957
  return parent;
907
958
  }
908
959
 
909
- /** How deep the reply chain ending at `answered` runs between `a` and `b`, read from both mailboxes' folders. */
960
+ /** The bounded reply chain between `a` and `b`, from exact message keys. */
910
961
  #replyChainDepth(a, b, answered) {
911
- const links = new Map([...this.store.links(a), ...this.store.links(b)]);
912
962
  let depth = 0, current = answered;
913
- while (current != null && depth <= REPLY_CHAIN_LIMIT && links.has(current)) { depth += 1; current = links.get(current); }
963
+ while (current != null && depth <= REPLY_CHAIN_LIMIT) {
964
+ const parent = this.store.replyParent(a, b, current);
965
+ if (parent === undefined) break;
966
+ depth += 1; current = parent;
967
+ }
914
968
  return depth;
915
969
  }
916
970
 
917
- #recordSend(sender, to, id, thread) {
918
- try {
919
- mkdirSync(this.#sentDir(sender), { recursive: true });
920
- appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread })}\n`);
921
- } catch { /* the log is best kept */ }
971
+ #recordSend(sender, to, id, thread, continuation = null) {
972
+ try { this.store.recordSend(sender, to, id, thread, continuation); } catch { /* the log is best kept */ }
922
973
  }
923
974
 
924
975
  /** A terminal's acknowledgement from the native watch's retained receipts, never from a filing (mail_send.rs
@@ -927,11 +978,8 @@ export class MailSend {
927
978
  if (!terminal) return { delivered: true, message_ids: [id], receipt: 'native-mail' };
928
979
  const parsed = parseAddress(address);
929
980
  const recipient = `${parsed.harness}:${parsed.session}`;
930
- let names = [];
931
- try { names = readdirSync(join(this.mailRoot, 'terminal-delivery')); } catch { /* none yet */ }
932
- for (const name of names) {
933
- const state = readJson(join(this.mailRoot, 'terminal-delivery', name, 'queue.json'));
934
- if (state?.recipient !== recipient) continue;
981
+ const state = this.store.terminalState(recipient);
982
+ if (state) {
935
983
  const receipt = (Array.isArray(state.receipts) ? state.receipts : []).find((item) => Array.isArray(item?.message_ids) && item.message_ids.includes(id));
936
984
  if (receipt) return receipt;
937
985
  return { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? null };
@@ -950,7 +998,7 @@ export class MailSend {
950
998
 
951
999
  /** rememberedName for each of `addresses`, the ledger read once. */
952
1000
  rememberedNames(addresses) {
953
- const names = readJson(join(this.mailRoot, 'names.json'));
1001
+ const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
954
1002
  const wanted = new Set(addresses), best = new Map();
955
1003
  if (names && typeof names === 'object') {
956
1004
  for (const [name, record] of Object.entries(names)) {
@@ -963,7 +1011,7 @@ export class MailSend {
963
1011
  }
964
1012
 
965
1013
  #rememberedAddress(name) {
966
- const record = readJson(join(this.mailRoot, 'names.json'))?.[name];
1014
+ const record = this.store.record('names', name);
967
1015
  if (!record || Date.now() - Math.max(record.seen_ms ?? 0, record.since_ms ?? 0) > NAME_KEPT_MS) return null;
968
1016
  return parseAddress(record.address)?.address ?? null;
969
1017
  }
@@ -971,10 +1019,7 @@ export class MailSend {
971
1019
  /** Each running session's name recorded (mail_route.rs `remember_names`): a name two sessions carry is left as it was;
972
1020
  * written only when a name is new, names another session, or its last listing is an hour old. */
973
1021
  rememberNames(sessions) {
974
- const path = join(this.mailRoot, 'names.json');
975
- let names;
976
- try { names = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') return; names = {}; }
977
- if (!names || typeof names !== 'object') return;
1022
+ const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
978
1023
  const carried = new Map();
979
1024
  for (const row of sessions) {
980
1025
  const name = row.name.split('@')[0];
@@ -982,7 +1027,7 @@ export class MailSend {
982
1027
  }
983
1028
  const now = Date.now();
984
1029
  const latest = (address) => Object.entries(names).filter(([, record]) => record.address === address).sort((x, y) => y[1].since_ms - x[1].since_ms)[0]?.[0] ?? null;
985
- let changed = false;
1030
+ const changed = [];
986
1031
  for (const [name, addresses] of carried) {
987
1032
  if (addresses.length !== 1) continue;
988
1033
  const [address] = addresses;
@@ -991,11 +1036,11 @@ export class MailSend {
991
1036
  if (current && latest(address) === name) continue;
992
1037
  const renewed = record?.address === address && latest(address) === name;
993
1038
  names[name] = { address, since_ms: renewed ? record.since_ms : now, seen_ms: now };
994
- changed = true;
1039
+ changed.push(name);
995
1040
  }
996
- if (!changed) return;
997
- const staged = `${path}.${process.pid}.tmp`;
998
- try { writeFileSync(staged, JSON.stringify(names)); renameSync(staged, path); } catch { /* kept for the next listing */ }
1041
+ if (!changed.length) return;
1042
+ try { this.store.transaction(() => { for (const name of changed) this.store.putRecord('names', name, names[name]); }); }
1043
+ catch { /* kept for the next listing */ }
999
1044
  }
1000
1045
  }
1001
1046
 
@@ -1017,11 +1062,3 @@ function questionAnswers(question, body) {
1017
1062
  if (!values || typeof values !== 'object' || Object.keys(values).length !== Object.keys(out).length) throw new Error('answer only the question ids shown');
1018
1063
  return out;
1019
1064
  }
1020
-
1021
- /** A file made once, owner-only, written whole and synced (mail_question.rs `create`). */
1022
- function createPrivate(dir, path, value) {
1023
- mkdirSync(dir, { recursive: true });
1024
- try { chmodSync(dir, 0o700); } catch { /* not ours to set */ }
1025
- const fd = openSync(path, 'wx', 0o600);
1026
- try { writeSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
1027
- }