@volter/supercode-teams 0.3.151 → 0.3.153

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,9 +3,7 @@
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';
@@ -44,7 +42,6 @@ const outcome = (code, text, receipt = null) => ({ code, text, receipt });
44
42
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
45
43
  /** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
46
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; };
47
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
48
45
 
49
46
  /** A delivery a door refused before anything was sent. */
50
47
  class Refused extends Error { constructor(kind, bytes = 0) { super(kind); this.kind = kind; this.bytes = bytes; } }
@@ -61,6 +58,16 @@ export class MailSend {
61
58
  Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, claudeProjects, log });
62
59
  this.handing = new Set(); // `<address>\n<id>` of each hand-over running here (handOver)
63
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 });
64
71
  }
65
72
 
66
73
  // ---- one send ----------------------------------------------------------------------------------------------------
@@ -71,6 +78,7 @@ export class MailSend {
71
78
  * came through (`{ principal, name }`), or null for this machine's own callers. Answers `{ code, text, receipt }`.
72
79
  */
73
80
  async send(caller, to, body, options = {}, context = {}) {
81
+ this.#active();
74
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.`);
75
83
  // Every stream earns its current route's grants before it can join the existing same-ID send in flight.
76
84
  let receiver = null;
@@ -79,8 +87,9 @@ export class MailSend {
79
87
  if (receiver.outcome && !this.live.codexKnown) { await this.#codexKnown(); receiver = await this.#receiver(to, this.live.sessions()); }
80
88
  if (receiver.outcome) return receiver.outcome;
81
89
  const refused = await this.#authorizeReceiver(context, receiver.continuation?.chain ?? [receiver.address]);
82
- if (refused) return refused;
90
+ if (refused) return this.#annotateReceiver(refused, receiver);
83
91
  }
92
+ this.#active();
84
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
85
94
  // under the same key meanwhile waits for the first and takes its outcome (sent once), never delivering it again; one
86
95
  // after a first that was not sent sends.
@@ -93,7 +102,10 @@ export class MailSend {
93
102
  const addresses = [...(first.continuation?.chain ?? []), ...(first.receipt?.recipients ?? []), first.receipt?.recipient].filter(Boolean);
94
103
  if (addresses.length) {
95
104
  const refused = await this.#authorizeReceiver(context, addresses);
96
- if (refused) return refused;
105
+ if (refused) return first.continuation ? this.#annotateReceiver(refused, {
106
+ address: first.continuation.recipient, continuation: first.continuation,
107
+ alias: ` Addressed to ${first.continuation.addressed}; its native continued-in record routes it to ${first.continuation.recipient}.`,
108
+ }) : refused;
97
109
  } 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.');
98
110
  return { ...outcome(0, `Already sent with --id ${options.id}: ${first.text} Nothing was sent again.`, first.receipt ?? null), ...(first.continuation ? { continuation: first.continuation } : {}) };
99
111
  }
@@ -140,6 +152,7 @@ export class MailSend {
140
152
  async #authorizeReceiver(context, addresses) {
141
153
  if (!context.authorizeRecipients) return null;
142
154
  const reason = await context.authorizeRecipients(addresses);
155
+ this.#active();
143
156
  return reason ? outcome(EXIT_REFUSED, `Not sent: ${reason}. Nothing was filed or delivered by this request.`) : null;
144
157
  }
145
158
 
@@ -174,7 +187,7 @@ export class MailSend {
174
187
  #answeredId(callerAddress, answered) {
175
188
  if (typeof answered !== 'string' || answered.length >= FULL_ID_LEN) return { id: answered ?? null };
176
189
  const found = this.store.findPrefixIn(callerAddress, answered).map((item) => item.envelope.id);
177
- for (const id of this.#sentIds(callerAddress)) if (id.startsWith(answered) && !found.includes(id)) found.push(id);
190
+ for (const id of this.store.sentIds(callerAddress, answered)) if (!found.includes(id)) found.push(id);
178
191
  if (found.length === 1) return { id: found[0] };
179
192
  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.`) };
180
193
  return { refused: outcome(EXIT_REFUSED, `Not sent: ${answered} names ${found.length} messages you received or sent (${found.join(', ')}). Give more of its id.`) };
@@ -275,9 +288,8 @@ export class MailSend {
275
288
  if (address === caller.address) return outcome(EXIT_REFUSED, 'Not sent: that address is your own session.');
276
289
  const door = parseAddress(address).harness === AGENT_HARNESS ? null : this.#door(address, sessions);
277
290
  const messageId = id == null && chosen ? chosen : messageIdFor(caller.address, id);
278
- const marker = this.#sentMarker(caller.address, messageId);
279
291
  const terminal = door?.kind === 'hook' && door.pane != null;
280
- let previous = null; try { previous = readFileSync(marker, 'utf8'); } catch { /* not sent before */ }
292
+ const previous = this.store.sentMarker(caller.address, messageId);
281
293
  if (previous !== null) {
282
294
  if (terminal && !queue) this.store.requestWake(address, messageId);
283
295
  return outcome(0, `Already sent with --id ${id ?? ''}: ${previous} Nothing was sent again.`, this.#deliveryReceipt(address, messageId, terminal));
@@ -339,7 +351,7 @@ export class MailSend {
339
351
  const text = code === 0
340
352
  ? `sent to ${name} (${harness}, ${door.kind}): ${what}. Message id ${shortId(messageId)}.${receiver.alias ?? ''}${idleNote} Don't poll; carry on.`
341
353
  : `${name} (${harness}): ${what}. Message id ${shortId(messageId)}.${idleNote}`;
342
- if (code === 0 && id != null) { try { mkdirSync(join(marker, '..'), { recursive: true }); writeFileSync(marker, text); } catch { /* the marker is best kept */ } }
354
+ if (code === 0 && id != null) { try { this.store.setSentMarker(caller.address, messageId, text); } catch { /* the marker is best kept */ } }
343
355
  return outcome(code, text, code === 0 ? this.#deliveryReceipt(address, messageId, terminal) : null);
344
356
  }
345
357
 
@@ -358,6 +370,7 @@ export class MailSend {
358
370
  * refused before anything was sent, and an Error with what failed.
359
371
  */
360
372
  async route(sent, to, door, wake, notifyWhenIdle) {
373
+ this.#active();
361
374
  this.store.open(to);
362
375
  let finalReply = false;
363
376
  let delivered;
@@ -421,7 +434,7 @@ export class MailSend {
421
434
  if (filedHere && wake) { try { this.store.requestWake(to, handed.id); } catch { /* the wake filed above stands */ } }
422
435
  throw Object.assign(new Error(relayed.detail), { unanswered: true });
423
436
  }
424
- if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
437
+ if (!relayed.delivered && !received) { if (filedHere) { try { this.store.remove(to, filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
425
438
  // relayed: a copy filed unread before this delivery (an earlier unanswered or queued one, held for the receiver's
426
439
  // own read) is moved to cur/, so that read never hands it over a second time
427
440
  if (relayed.delivered && !received) {
@@ -565,6 +578,7 @@ export class MailSend {
565
578
  try { await this.agents.resume(to); } catch (error) { throw new Error(`not resumed: ${error.message}`); }
566
579
  const started = Date.now();
567
580
  for (;;) {
581
+ this.#active();
568
582
  door = this.#door(to, this.live.sessions());
569
583
  if (door) break;
570
584
  if (Date.now() - started > RESUME_WAIT_MS) return { how: 'not_running', text: 'resumed; the message waits in its mailbox' };
@@ -650,7 +664,7 @@ export class MailSend {
650
664
  if (typeof key !== 'string' || !key || Buffer.byteLength(key) > 256) return refused('invalid_key', 'idempotency_key needs 1–256 bytes');
651
665
  id = `m-${blake3Hex(JSON.stringify([sender, receiver, key]))}`.slice(0, 26);
652
666
  const previous = this.store.findIn(receiver, id);
653
- const saved = previous ? readJson(previous.path) : null;
667
+ const saved = previous ? this.store.body(previous) : null;
654
668
  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))) {
655
669
  return refused('idempotency_conflict', 'this key already names different mail');
656
670
  }
@@ -785,9 +799,11 @@ export class MailSend {
785
799
  * (mail_route.rs `type_user_turns`).
786
800
  */
787
801
  async typeUserTurns(address, pane) {
802
+ this.#active();
788
803
  const typed = [];
789
804
  const harness = parseAddress(address).harness;
790
805
  for (const waiting of this.store.userTurns(address)) {
806
+ this.#active();
791
807
  const claimed = this.store.claimUserTurn(address, waiting);
792
808
  if (!claimed) break;
793
809
  let result;
@@ -817,9 +833,10 @@ export class MailSend {
817
833
  * `{ outcome: 'delivered' | 'filed' | 'later', detail }`.
818
834
  */
819
835
  async handOver(address, id, { wake = false } = {}) {
836
+ this.#active();
820
837
  const found = this.store.findIn(address, id);
821
838
  if (!found) return { outcome: 'filed', detail: 'not in its mailbox' };
822
- const sent = readJson(found.path);
839
+ const sent = this.store.body(found);
823
840
  if (!sent) return { outcome: 'later', detail: 'its envelope could not be read' };
824
841
  if (found.state !== 'unread') return { outcome: 'filed', detail: 'already read' };
825
842
  // one already handed to its receiver's door (that delivery's claim, the door's or the carrier's: a daemon that ended
@@ -868,8 +885,10 @@ export class MailSend {
868
885
  return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
869
886
  } finally {
870
887
  this.handing.delete(key);
871
- const after = this.store.wakeState(address, id);
872
- this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
888
+ if (!this.stopped) {
889
+ const after = this.store.wakeState(address, id);
890
+ this.store.setWakeState(address, id, { ...after, handover_at_ms: null });
891
+ }
873
892
  }
874
893
  }
875
894
 
@@ -877,13 +896,13 @@ export class MailSend {
877
896
 
878
897
  /** Answer the native question `id` asked in `to` (mail_question.rs `reply`): only its recorded creator answers, once. */
879
898
  async #questionReply(caller, to, id, body, link) {
880
- const dir = join(this.mailRoot, 'native-questions');
881
- const path = (suffix) => join(dir, `${id}.${suffix}`);
882
- const question = readJson(path('json'));
899
+ const key = (suffix) => `${id}.${suffix}`;
900
+ const question = this.store.record('native_questions', key('json'));
883
901
  if (!question) return outcome(EXIT_FAILED, `Not answered: question ${id} could not be read.`);
884
902
  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.');
903
+ 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.');
885
904
  if (parseAddress(to).harness === 'claude-code') {
886
- const waiting = readJson(path('waiting'));
905
+ const waiting = this.store.record('native_questions', key('waiting'));
887
906
  if (!waiting) return outcome(EXIT_REFUSED, 'Not answered: the native question hook is no longer waiting.');
888
907
  let alive = false; try { process.kill(waiting.pid, 0); alive = true; } catch (error) { alive = error.code === 'EPERM'; }
889
908
  if (!Number.isInteger(waiting.pid) || !alive) return outcome(EXIT_REFUSED, 'Not answered: the native question hook has ended.');
@@ -894,24 +913,25 @@ export class MailSend {
894
913
  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 }),
895
914
  answers,
896
915
  };
897
- try { createPrivate(dir, path('claim'), reply); }
898
- catch (error) {
899
- 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.');
900
- throw error;
901
- }
902
- this.store.deliver(to, reply.envelope);
903
- // published to the hook only once the whole reply is in mail
904
- linkSync(path('claim'), path('reply'));
916
+ const claimed = this.store.transaction(() => {
917
+ if (!this.store.putRecord('native_questions', key('claim'), reply, { create: true })) return false;
918
+ this.store.deliver(to, reply.envelope);
919
+ this.store.putRecord('native_questions', key('reply'), reply, { create: true });
920
+ return true;
921
+ });
922
+ if (!claimed) return outcome(EXIT_REFUSED, 'An answer is already recorded for this question. Nothing was submitted again; inspect its thread for delivery.');
905
923
  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.`);
906
- const binding = readJson(join(dir, `binding-${blake3Hex(to)}.json`));
924
+ const binding = this.store.record('native_questions', `binding-${blake3Hex(to)}.json`);
907
925
  if (!binding?.socket_path) return outcome(EXIT_STORED, 'Answer recorded but NOT delivered: this launch has no native question endpoint. Nothing was typed.');
908
926
  let result;
909
927
  try { result = await this.codexAnswer({ socketPath: binding.socket_path, threadId: parseAddress(to).session, answer: { question: question.native, answers: reply.answers } }); }
910
928
  catch (error) { result = { delivered: false, reason: error.code ?? 'native-question-error' }; }
911
929
  if (result?.delivered === true) {
912
- const filed = this.store.findIn(to, reply.envelope.id);
913
- if (filed?.state === 'unread') this.store.markRead(to, filed.path);
914
- try { createPrivate(dir, path('delivered'), Date.now()); } catch (error) { if (error.code !== 'EEXIST') throw error; }
930
+ this.store.transaction(() => {
931
+ const filed = this.store.findIn(to, reply.envelope.id);
932
+ if (filed?.state === 'unread') this.store.markRead(to, filed.path);
933
+ this.store.putRecord('native_questions', key('delivered'), Date.now(), { create: true });
934
+ });
915
935
  return outcome(0, `Answer ${shortId(reply.envelope.id)} delivered to native question ${id}.`);
916
936
  }
917
937
  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.`);
@@ -919,29 +939,10 @@ export class MailSend {
919
939
 
920
940
  // ---- the send log and its guards ---------------------------------------------------------------------------------
921
941
 
922
- #sentDir(sender) { return join(this.mailRoot, 'sent', blake3Hex(sender).slice(0, 24)); }
923
- #sentMarker(sender, id) { return join(this.#sentDir(sender), id); }
924
-
925
- #logOf(sender) {
926
- let text = '';
927
- try { text = readFileSync(join(this.#sentDir(sender), 'log.jsonl'), 'utf8'); } catch { return []; }
928
- return text.split('\n').map((line) => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean);
929
- }
930
-
931
- #sentIds(sender) { return this.#logOf(sender).map((entry) => entry.id).filter((id) => typeof id === 'string'); }
932
- #sentThread(sender, id) { return this.#logOf(sender).find((entry) => entry.id === id && typeof entry.thread === 'string')?.thread ?? null; }
942
+ #sentThread(sender, id) { return this.store.sentThread(sender, id); }
933
943
 
934
944
  /** The thread any author on this machine sent `id` in, as its send log keeps it. */
935
- #sentThreadHere(id) {
936
- let names = [];
937
- try { names = readdirSync(join(this.mailRoot, 'sent')); } catch { return null; }
938
- for (const name of names) {
939
- let text = '';
940
- try { text = readFileSync(join(this.mailRoot, 'sent', name, 'log.jsonl'), 'utf8'); } catch { continue; }
941
- 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; }
942
- }
943
- return null;
944
- }
945
+ #sentThreadHere(id) { return this.store.sentThreadHere(id); }
945
946
 
946
947
  /** The thread `parent` is filed in on this machine (mailbox.rs `filed_thread`). */
947
948
  #filedThread(parent) {
@@ -959,19 +960,19 @@ export class MailSend {
959
960
  return parent;
960
961
  }
961
962
 
962
- /** How deep the reply chain ending at `answered` runs between `a` and `b`, read from both mailboxes' folders. */
963
+ /** The bounded reply chain between `a` and `b`, from exact message keys. */
963
964
  #replyChainDepth(a, b, answered) {
964
- const links = new Map([...this.store.links(a), ...this.store.links(b)]);
965
965
  let depth = 0, current = answered;
966
- while (current != null && depth <= REPLY_CHAIN_LIMIT && links.has(current)) { depth += 1; current = links.get(current); }
966
+ while (current != null && depth <= REPLY_CHAIN_LIMIT) {
967
+ const parent = this.store.replyParent(a, b, current);
968
+ if (parent === undefined) break;
969
+ depth += 1; current = parent;
970
+ }
967
971
  return depth;
968
972
  }
969
973
 
970
974
  #recordSend(sender, to, id, thread, continuation = null) {
971
- try {
972
- mkdirSync(this.#sentDir(sender), { recursive: true });
973
- appendFileSync(join(this.#sentDir(sender), 'log.jsonl'), `${JSON.stringify({ to, at_ms: Date.now(), id, thread, ...(continuation ? { continuation } : {}) })}\n`);
974
- } catch { /* the log is best kept */ }
975
+ try { this.store.recordSend(sender, to, id, thread, continuation); } catch { /* the log is best kept */ }
975
976
  }
976
977
 
977
978
  /** A terminal's acknowledgement from the native watch's retained receipts, never from a filing (mail_send.rs
@@ -980,11 +981,8 @@ export class MailSend {
980
981
  if (!terminal) return { delivered: true, message_ids: [id], receipt: 'native-mail' };
981
982
  const parsed = parseAddress(address);
982
983
  const recipient = `${parsed.harness}:${parsed.session}`;
983
- let names = [];
984
- try { names = readdirSync(join(this.mailRoot, 'terminal-delivery')); } catch { /* none yet */ }
985
- for (const name of names) {
986
- const state = readJson(join(this.mailRoot, 'terminal-delivery', name, 'queue.json'));
987
- if (state?.recipient !== recipient) continue;
984
+ const state = this.store.terminalState(recipient);
985
+ if (state) {
988
986
  const receipt = (Array.isArray(state.receipts) ? state.receipts : []).find((item) => Array.isArray(item?.message_ids) && item.message_ids.includes(id));
989
987
  if (receipt) return receipt;
990
988
  return { delivered: false, queued: true, message_ids: [id], reason: state.active?.error ?? null };
@@ -1003,7 +1001,7 @@ export class MailSend {
1003
1001
 
1004
1002
  /** rememberedName for each of `addresses`, the ledger read once. */
1005
1003
  rememberedNames(addresses) {
1006
- const names = readJson(join(this.mailRoot, 'names.json'));
1004
+ const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
1007
1005
  const wanted = new Set(addresses), best = new Map();
1008
1006
  if (names && typeof names === 'object') {
1009
1007
  for (const [name, record] of Object.entries(names)) {
@@ -1016,7 +1014,7 @@ export class MailSend {
1016
1014
  }
1017
1015
 
1018
1016
  #rememberedAddress(name) {
1019
- const record = readJson(join(this.mailRoot, 'names.json'))?.[name];
1017
+ const record = this.store.record('names', name);
1020
1018
  if (!record || Date.now() - Math.max(record.seen_ms ?? 0, record.since_ms ?? 0) > NAME_KEPT_MS) return null;
1021
1019
  return parseAddress(record.address)?.address ?? null;
1022
1020
  }
@@ -1024,10 +1022,7 @@ export class MailSend {
1024
1022
  /** Each running session's name recorded (mail_route.rs `remember_names`): a name two sessions carry is left as it was;
1025
1023
  * written only when a name is new, names another session, or its last listing is an hour old. */
1026
1024
  rememberNames(sessions) {
1027
- const path = join(this.mailRoot, 'names.json');
1028
- let names;
1029
- try { names = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') return; names = {}; }
1030
- if (!names || typeof names !== 'object') return;
1025
+ const names = Object.fromEntries(this.store.records('names').map(({ key, value }) => [key, value]));
1031
1026
  const carried = new Map();
1032
1027
  for (const row of sessions) {
1033
1028
  const name = row.name.split('@')[0];
@@ -1035,7 +1030,7 @@ export class MailSend {
1035
1030
  }
1036
1031
  const now = Date.now();
1037
1032
  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;
1038
- let changed = false;
1033
+ const changed = [];
1039
1034
  for (const [name, addresses] of carried) {
1040
1035
  if (addresses.length !== 1) continue;
1041
1036
  const [address] = addresses;
@@ -1044,11 +1039,11 @@ export class MailSend {
1044
1039
  if (current && latest(address) === name) continue;
1045
1040
  const renewed = record?.address === address && latest(address) === name;
1046
1041
  names[name] = { address, since_ms: renewed ? record.since_ms : now, seen_ms: now };
1047
- changed = true;
1042
+ changed.push(name);
1048
1043
  }
1049
- if (!changed) return;
1050
- const staged = `${path}.${process.pid}.tmp`;
1051
- try { writeFileSync(staged, JSON.stringify(names)); renameSync(staged, path); } catch { /* kept for the next listing */ }
1044
+ if (!changed.length) return;
1045
+ try { this.store.transaction(() => { for (const name of changed) this.store.putRecord('names', name, names[name]); }); }
1046
+ catch { /* kept for the next listing */ }
1052
1047
  }
1053
1048
  }
1054
1049
 
@@ -1070,11 +1065,3 @@ function questionAnswers(question, body) {
1070
1065
  if (!values || typeof values !== 'object' || Object.keys(values).length !== Object.keys(out).length) throw new Error('answer only the question ids shown');
1071
1066
  return out;
1072
1067
  }
1073
-
1074
- /** A file made once, owner-only, written whole and synced (mail_question.rs `create`). */
1075
- function createPrivate(dir, path, value) {
1076
- mkdirSync(dir, { recursive: true });
1077
- try { chmodSync(dir, 0o700); } catch { /* not ours to set */ }
1078
- const fd = openSync(path, 'wx', 0o600);
1079
- try { writeSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
1080
- }