@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.
@@ -7,33 +7,29 @@
7
7
  // - a send turn gets one `SendMessage` tool call carrying the queued `to` and `message`, byte for byte;
8
8
  // - the turn after a tool result, or with nothing queued, gets RELAY_STATUS_LINE and ends.
9
9
  // No model runs and nothing is billed. The gate hook still checks every tool call against the queue, and the receipt is
10
- // still Claude's own `SendMessage` result. A relay's API key names its folder under `<mail root>/relays/`. The machine
11
- // daemon serves it on loopback, on the port recorded in `<mail root>/relay-endpoint.json` (a restarted daemon binds the
12
- // same port again).
10
+ // still Claude's own `SendMessage` result. A relay's API key names its record in
11
+ // the mail database. The loopback port is recorded there too, so a restarted
12
+ // daemon can bind the same port again.
13
13
  import { createServer } from 'node:http';
14
14
  import { randomBytes } from 'node:crypto';
15
- import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
16
- import { join } from 'node:path';
17
15
 
18
16
  /** The text every relay turn ends with. Claude quotes a session's last line in its idle notices, so this is where the
19
17
  * correction reaches the agent. */
20
18
  export const RELAY_STATUS_LINE = "relay, not the session's status";
21
19
 
22
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
23
20
  const newId = () => `m-${randomBytes(12).toString('hex')}`;
24
21
 
25
22
  /**
26
- * Serve the endpoint for `mailRoot`'s relays: answers `{ url, close() }` once it listens. The port the record names is
23
+ * Serve the endpoint for `store`'s relays: answers `{ url, close() }` once it listens. The port the record names is
27
24
  * taken again when it is free, so relays started before a restart keep reaching it.
28
25
  */
29
- export async function serveRelayEndpoint(mailRoot, { log = () => {} } = {}) {
30
- const record = join(mailRoot, 'relay-endpoint.json');
26
+ export async function serveRelayEndpoint(store, { log = () => {} } = {}) {
31
27
  const server = createServer((request, response) => {
32
28
  const chunks = [];
33
29
  request.on('data', (chunk) => chunks.push(chunk));
34
30
  request.on('error', () => {});
35
31
  request.on('end', () => {
36
- const [status, type, body] = answer(mailRoot, request, Buffer.concat(chunks));
32
+ const [status, type, body] = answer(store, request, Buffer.concat(chunks));
37
33
  response.writeHead(status, { 'content-type': type, 'content-length': Buffer.byteLength(body) });
38
34
  response.end(body);
39
35
  });
@@ -45,7 +41,7 @@ export async function serveRelayEndpoint(mailRoot, { log = () => {} } = {}) {
45
41
  server.once('error', failed); server.once('listening', done);
46
42
  server.listen(port, '127.0.0.1');
47
43
  });
48
- const recorded = readJson(record)?.port;
44
+ const recorded = store.record('relay_endpoint', 'port')?.port;
49
45
  try { await listen(Number.isInteger(recorded) ? recorded : 0); }
50
46
  catch (error) {
51
47
  if (!Number.isInteger(recorded)) throw error;
@@ -53,33 +49,29 @@ export async function serveRelayEndpoint(mailRoot, { log = () => {} } = {}) {
53
49
  await listen(0);
54
50
  }
55
51
  const port = server.address().port;
56
- mkdirSync(mailRoot, { recursive: true });
57
- const staging = `${record}.tmp`;
58
- writeFileSync(staging, JSON.stringify({ port }));
59
- renameSync(staging, record);
52
+ store.putRecord('relay_endpoint', 'port', { port });
60
53
  return { url: `http://127.0.0.1:${port}`, port, close: () => new Promise((resolve) => server.close(() => resolve())) };
61
54
  }
62
55
 
63
56
  /** The relay folder an API key names, when it is one. */
64
- function relayFolder(mailRoot, request) {
57
+ function relayKey(store, request) {
65
58
  const key = request.headers['x-api-key'];
66
59
  if (typeof key !== 'string' || !key || !/^[0-9a-fA-F]+$/.test(key)) return null;
67
- const folder = join(mailRoot, 'relays', key);
68
- try { return statSync(folder).isDirectory() ? folder : null; } catch { return null; }
60
+ return store.relayKnown(key) ? key : null;
69
61
  }
70
62
 
71
63
  /** One request's answer: `[status, content type, body]`. */
72
- function answer(mailRoot, request, raw) {
64
+ function answer(store, request, raw) {
73
65
  const error = (status, message) => [status, 'application/json', JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message } })];
74
66
  if (request.method !== 'POST') return error(404, 'not found');
75
- const folder = relayFolder(mailRoot, request);
76
- if (!folder) return error(401, 'not a supercode relay');
67
+ const key = relayKey(store, request);
68
+ if (!key) return error(401, 'not a supercode relay');
77
69
  const path = String(request.url ?? '').split('?')[0];
78
70
  if (path.endsWith('/v1/messages/count_tokens')) return [200, 'application/json', JSON.stringify({ input_tokens: 1 })];
79
71
  if (!path.endsWith('/v1/messages')) return error(404, 'not found');
80
72
  let body;
81
73
  try { body = JSON.parse(raw.toString('utf8')); } catch { return error(400, 'unreadable request'); }
82
- const queued = readJson(join(folder, 'queue.json'));
74
+ const queued = store.record('relays', `${key}/queue.json`);
83
75
  const content = queued && typeof queued.to === 'string' && typeof queued.message === 'string' && !afterToolResult(body)
84
76
  ? { type: 'tool_use', id: `toolu_${newId()}`, name: 'SendMessage', input: { to: queued.to, message: queued.message } }
85
77
  : { type: 'text', text: RELAY_STATUS_LINE };
package/mail/relay.mjs CHANGED
@@ -1,15 +1,10 @@
1
- // A Claude session supercode does not control is reached through Claude's own SendMessage, made by a relay: a `claude
2
- // --print` runtime that speaks for the sender, hosted by this daemon's `harness serve` (crates/harness/src/claude_relay.rs).
3
- // The daemon drives a relay send through the relay's folder, as the native sender did: `queue.json` names the one send its
4
- // gate allows, the relay's hooks write Claude's answer to `receipt.json`, and the turn that makes the send goes in by
5
- // harness serve's own `runtimes.send_input`; its runtime is started and closed by `runtimes.start` and `runtimes.close`.
6
- // No native process runs per delivery. The relay's hooks (`message gate`, `relay-inbound`, `relay-receipt`) stay native
7
- // doors, run by the relay's Claude (docs/architecture/overview.md, "Pinned native doors"). Its folder is the native
8
- // sender's own (`<mail>/relays/<blake3(name)[..24]>/`, its record, settings and send log), so the native `message
9
- // watch`, which retires relays until step 4, reads what the daemon writes.
10
- import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
1
+ // A Claude relay uses the stock harness's SendMessage door. This daemon owns
2
+ // its queue, receipts and runtime records in SQLite; Claude reads only its hook
3
+ // configuration in a separate runtime directory. Native runtime start/input/
4
+ // close doors retain their observed-result and unanswered-operation semantics.
5
+ import { mkdirSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
11
6
  import { connect } from 'node:net';
12
- import { join } from 'node:path';
7
+ import { basename, dirname, join } from 'node:path';
13
8
  import { blake3Hex, shortId } from './envelope.mjs';
14
9
 
15
10
  /** Model name a relay's requests carry; the relay endpoint answers them without any model. */
@@ -57,7 +52,6 @@ const CODEX_RELAY_KEPT_MS = 7 * 24 * 60 * 60_000;
57
52
  const LOST_START_KEPT_MS = 5 * 60_000;
58
53
 
59
54
  const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
60
- const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
61
55
  const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
62
56
 
63
57
  /** Registry name of the relay that represents `name` on `machine` (not `name@machine`: SendMessage refuses an `@`). */
@@ -91,8 +85,11 @@ export class Relays {
91
85
  * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
92
86
  * `runtimeNamed(receiptId)` one receipt read by its own name.
93
87
  */
94
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, hooksServed = () => true, log = () => {} }) {
95
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, hooksServed, log });
88
+ constructor({ mailRoot, store, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, hooksServed = () => true, log = () => {} }) {
89
+ Object.assign(this, { mailRoot, store, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, hooksServed, log });
90
+ this.runtimeRoot = join(dirname(mailRoot), 'relays');
91
+ this.stopped = false;
92
+ this.receipts = new Set();
96
93
  this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
97
94
  this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
98
95
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
@@ -103,6 +100,7 @@ export class Relays {
103
100
  * daemon starts every relay and holds every relay's send lock, so it alone closes one (a closer that did not hold the
104
101
  * lock closed relays mid-send, and every send after paid a relay's start). */
105
102
  #cap() {
103
+ if (this.stopped) return;
106
104
  const kept = [...this.running].sort(([, a], [, b]) => b.usedAt - a.usedAt);
107
105
  for (const [directory, relay] of kept.slice(RELAYS_RUNNING)) {
108
106
  if (this.sending.has(directory)) continue;
@@ -121,7 +119,7 @@ export class Relays {
121
119
  for (const [directory, lostAt] of this.lost) {
122
120
  if (Date.now() - lostAt > LOST_START_KEPT_MS) { this.lost.delete(directory); continue; }
123
121
  if (this.sending.has(directory)) continue;
124
- const named = readJson(join(directory, 'relay.json'))?.runtime_id;
122
+ const named = this.store.record('relays', `${basename(directory)}/relay.json`)?.runtime_id;
125
123
  for (const ids of this.runtimesIn('claude-code', directory)) {
126
124
  if (ids.includes(named)) continue;
127
125
  void this.#locked(directory, async () => {
@@ -147,32 +145,43 @@ export class Relays {
147
145
 
148
146
  /** The address a relay folder speaks for, as its inbound hook names it (its settings), or null. */
149
147
  #representedOf(directory) {
150
- try { return /relay-inbound '([^']+)'/.exec(readFileSync(join(directory, 'settings.json'), 'utf8'))?.[1] ?? null; } catch { return null; }
148
+ const settings = this.store.record('relays', `${basename(directory)}/settings.json`);
149
+ return /relay-inbound '([^']+)'/.exec(JSON.stringify(settings))?.[1] ?? null;
151
150
  }
152
151
 
153
152
  async #retireFolder(directory, expected = null) {
154
- try { statSync(directory); } catch { return false; }
153
+ if (this.stopped) return false;
154
+ if (!this.store.relayKnown(basename(directory))) return false;
155
155
  if (expected && this.#representedOf(directory) !== expected) return false;
156
156
  return this.#locked(directory, async () => {
157
- // read again under its lock: a send that ran meanwhile writes its own sender's settings
157
+ if (this.stopped) return false;
158
158
  if (expected && this.#representedOf(directory) !== expected) return false;
159
- const record = readJson(join(directory, 'relay.json'));
159
+ const record = this.store.record('relays', `${basename(directory)}/relay.json`);
160
160
  if (record?.runtime_id) {
161
161
  try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, CLOSE_MS); }
162
162
  catch (error) {
163
- // a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
164
- if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
163
+ if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${basename(directory)} was not retired: ${error.message}`); return false; }
165
164
  }
166
165
  }
166
+ if (this.stopped) return false;
167
167
  this.running.delete(directory); this.lost.delete(directory);
168
168
  clearTimeout(this.remoteKept.get(directory)); this.remoteKept.delete(directory);
169
- try { rmSync(directory, { recursive: true, force: true }); } catch (error) { this.log(`relay ${directory.split(/[\\/]/).pop()}: its folder was not removed: ${error.message}`); return false; }
169
+ this.store.transaction(() => {
170
+ for (const file of ['relay.json', 'queue.json', 'receipt.json', 'sent.json', 'settings.json', 'turn.txt']) this.store.removeRecord('relays', `${basename(directory)}/${file}`);
171
+ });
172
+ // Only the configuration this daemon materializes is removed. Other
173
+ // runtime files are retained; an old Maildir is handled by the importer.
174
+ if (dirname(directory) === this.runtimeRoot) {
175
+ try { unlinkSync(join(directory, 'settings.json')); } catch (error) { if (error.code !== 'ENOENT') this.log(`relay configuration retained: ${error.message}`); }
176
+ try { rmdirSync(directory); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) this.log(`relay directory retained: ${error.message}`); }
177
+ }
170
178
  return true;
171
179
  });
172
180
  }
173
181
 
174
182
  /** A relay speaking for a sender on another machine is retired REMOTE_RELAY_KEPT_MS after its last send. */
175
183
  #keepRemote(directory, quietSinceMs = Date.now()) {
184
+ if (this.stopped) return;
176
185
  clearTimeout(this.remoteKept.get(directory));
177
186
  const timer = setTimeout(() => { this.remoteKept.delete(directory); void this.#retireFolder(directory); }, Math.max(0, quietSinceMs + REMOTE_RELAY_KEPT_MS - Date.now()));
178
187
  timer.unref?.();
@@ -185,22 +194,25 @@ export class Relays {
185
194
  * `running(address)` says whether a session here runs.
186
195
  */
187
196
  async sweepAtStart(running) {
188
- let folders = [];
189
- try { folders = readdirSync(join(this.mailRoot, 'relays')); } catch { return; }
197
+ if (this.stopped) return;
198
+ const records = this.store.records('relays');
199
+ const folders = [...new Set(records.map(({ key }) => key.split('/')[0]))];
200
+ const times = new Map(records.map(({ key, updated_at_ms }) => [key, updated_at_ms]));
190
201
  let retired = 0;
191
202
  for (const folder of folders) {
192
- const directory = join(this.mailRoot, 'relays', folder);
203
+ if (this.stopped) return;
204
+ const directory = join(this.runtimeRoot, folder);
193
205
  const represented = this.#representedOf(directory);
194
206
  if (!represented) continue;
195
207
  const [, machine, harness] = represented.split(':');
196
208
  if (harness === 'board' || harness === 'operator') continue;
197
209
  if (machine !== this.machine) {
198
- let last = 0; try { last = statSync(join(directory, 'receipt.json')).mtimeMs; } catch { /* never sent */ }
210
+ const last = times.get(`${folder}/receipt.json`) ?? 0;
199
211
  if (Date.now() - last < REMOTE_RELAY_KEPT_MS) { this.#keepRemote(directory, last); continue; }
200
212
  } else if (running(represented)) continue;
201
213
  else if (harness === 'codex') {
202
214
  // a Codex session the map does not hold may be idle, not ended: its relay is kept until unused for a week
203
- let last = 0; for (const file of ['receipt.json', 'relay.json']) { try { last = Math.max(last, statSync(join(directory, file)).mtimeMs); } catch { /* none */ } }
215
+ const last = Math.max(times.get(`${folder}/receipt.json`) ?? 0, times.get(`${folder}/relay.json`) ?? 0);
204
216
  if (Date.now() - last < CODEX_RELAY_KEPT_MS) continue;
205
217
  } else if (harness !== 'claude-code') continue;
206
218
  // only a Claude session is provably ended when the map does not hold it (its registry lists every live Claude
@@ -218,7 +230,10 @@ export class Relays {
218
230
  const turn = previous.then(() => mine);
219
231
  this.sending.set(directory, turn);
220
232
  await previous;
221
- try { return await work(); }
233
+ try {
234
+ if (this.stopped) throw new Error('the daemon is stopping; no relay operation was submitted');
235
+ return await work();
236
+ }
222
237
  finally {
223
238
  release();
224
239
  if (this.sending.get(directory) === turn) this.sending.delete(directory);
@@ -228,11 +243,12 @@ export class Relays {
228
243
  /** The relay that speaks for `represented`, known as `representedName`: its name and its files. */
229
244
  spec(represented, representedName) {
230
245
  const name = relayName(String(representedName).split('@')[0], String(represented).split(':')[1]);
231
- const directory = join(this.mailRoot, 'relays', blake3Hex(name).slice(0, 24));
246
+ const key = blake3Hex(name).slice(0, 24);
247
+ const directory = join(this.runtimeRoot, key);
232
248
  return {
233
249
  represented, name, directory,
234
- queue: join(directory, 'queue.json'), receipt: join(directory, 'receipt.json'), settings: join(directory, 'settings.json'),
235
- record: join(directory, 'relay.json'), sent: join(directory, 'sent.json'),
250
+ queue: `${key}/queue.json`, receipt: `${key}/receipt.json`, settings: join(directory, 'settings.json'),
251
+ settingsKey: `${key}/settings.json`, record: `${key}/relay.json`, sent: `${key}/sent.json`,
236
252
  };
237
253
  }
238
254
 
@@ -261,7 +277,7 @@ export class Relays {
261
277
  #pid(spec, runtime) {
262
278
  const child = runtime?.metadata?.child_pid;
263
279
  if (Number.isInteger(child)) return child;
264
- const record = readJson(spec.record);
280
+ const record = this.store.record('relays', spec.record);
265
281
  return record?.runtime_id === runtime?.runtime_session_id && Number.isInteger(record?.pid) ? record.pid : null;
266
282
  }
267
283
 
@@ -272,24 +288,25 @@ export class Relays {
272
288
  // build's native) would answer none of them, and every mail the relay is handed would be blocked unfiled
273
289
  if (!this.hooksServed()) throw new Error('relays run their hooks through the machine\'s stable supercode, and none here serves them (no stable supercode was found, or it is a dev or cargo build); install supercode from npm to send to Claude sessions by relay');
274
290
  const endpoint = await this.endpoint();
291
+ if (this.stopped) throw new Error('the daemon is stopping; no relay turn was submitted');
275
292
  step('relay endpoint');
276
293
  if (!endpoint) throw new Error('the relay endpoint is not served by this machine\'s daemon (its log says why); a Claude session can be sent mail once the daemon starts again');
277
294
  // written on every send: Claude Code reloads a changed settings file, so a running relay takes the hooks this
278
295
  // supercode installs
279
- writeFileSync(spec.settings, JSON.stringify(this.#settings(spec), null, 2));
280
- const record = readJson(spec.record);
296
+ const settings = this.#settings(spec);
297
+ this.store.putRecord('relays', spec.settingsKey, settings);
298
+ writeFileSync(spec.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
299
+ const record = this.store.record('relays', spec.record);
281
300
  if (record?.runtime_id) {
282
301
  const runtime = this.runtimeOf('claude-code', record.runtime_id);
283
- if (!stale && runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
302
+ if (!stale && record.runtime_directory === spec.directory && runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
284
303
  step('runtime reused');
285
304
  return { runtime, connection: record.connection };
286
305
  }
287
306
  // why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
288
307
  step(`runtime not reused (${stale ? 'it held a stale turn' : !runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
289
- // closed through the owner's door even after its receipt went. One whose receipt is gone, or whose process is
290
- // known and ended, is only serve's record of it: let go without holding this send (yuerans, 2026-10-06: 2.3-18 s
291
- // of every send under load). One that may still run, its process unknown (no child pid, a record of another
292
- // runtime) or alive, is closed before its successor starts in the same folder, as before.
308
+ // A possibly live predecessor is closed through its owner before this
309
+ // daemon starts a successor in the new runtime directory.
293
310
  const close = this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, CLOSE_MS);
294
311
  const pid = runtime ? this.#pid(spec, runtime) : null;
295
312
  if (runtime && !(Number.isInteger(pid) && !alive(pid))) {
@@ -301,6 +318,7 @@ export class Relays {
301
318
  step('old runtime let go (runtimes.close, not waited on)');
302
319
  }
303
320
  }
321
+ if (this.stopped) throw new Error('the daemon is stopping; no successor relay was started');
304
322
  const key = spec.directory.split(/[\\/]/).pop();
305
323
  const started = await this.#start(spec, {
306
324
  harness: 'claude-code',
@@ -317,17 +335,15 @@ export class Relays {
317
335
  const connection = typeof started.connection === 'string' ? started.connection : '';
318
336
  const pid = Number.isInteger(started?.handle?.endpoint?.pid) ? started.handle.endpoint.pid : null;
319
337
  step('runtime started (runtimes.start)');
320
- writeFileSync(spec.record, JSON.stringify({ runtime_id: runtimeId, pid, endpoint, connection }));
338
+ this.store.putRecord('relays', spec.record, { runtime_id: runtimeId, pid, endpoint, connection, runtime_directory: spec.directory });
321
339
  // the daemon's from its start: counted by the cap even when the rest of this start fails
322
340
  this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
323
341
  // harness serve writes its receipt before it answers the start; the map learns it from its watch a moment later, so
324
342
  // one it does not hold yet is read by its own name (the receipt the runtime's terminal launch opens), never waited on
325
343
  const known = this.runtimeOf('claude-code', runtimeId);
326
344
  if (known) { step('its receipt held'); return { runtime: known, connection }; }
327
- // its receipt, from whichever answers first: the map's watch (it learns the receipt file serve wrote before it
328
- // answered the start, about a second later), or serve naming it (terminal instructions), each for at most
329
- // RECEIPT_WAIT_MS. Serve alone, bounded at 5 s, refused every start on a loaded machine (yuerans, 2026-10-06:
330
- // "the relay started but registered no live runtime").
345
+ // Either the live map or terminal instructions can identify the receipt
346
+ // written by serve. Neither timeout establishes that no runtime exists.
331
347
  let receiptId = null;
332
348
  this.serve('harness.v1.runtimes.terminal_instructions', { connection }, RECEIPT_WAIT_MS).then((answer) => {
333
349
  const launch = answer?.launch;
@@ -347,25 +363,39 @@ export class Relays {
347
363
  return { runtime, connection };
348
364
  }
349
365
 
350
- /** Claude's receipt for the queued send, waited on by a watch of the relay's folder (and its process checked at least
351
- * every half second): a relay that ended or a send not confirmed within SEND_TIMEOUT_MS is said. */
366
+ /** The committed receipt wakes its waiter. The timer checks only whether
367
+ * the runtime ended; a deadline remains an unknown delivery outcome. */
352
368
  #receipt(spec, pid) {
353
369
  return new Promise((resolve) => {
354
- let done = false, watcher = null, timer = null, deadline = null;
355
- const finish = (answer) => { if (done) return; done = true; clearInterval(timer); clearTimeout(deadline); try { watcher?.close(); } catch { /* closed */ } resolve(answer); };
370
+ let done = false, timer = null, deadline = null;
371
+ const finish = (answer) => {
372
+ if (done) return;
373
+ done = true; clearInterval(timer); clearTimeout(deadline);
374
+ this.store.database.off('record', changed); this.receipts.delete(cancel);
375
+ resolve(answer);
376
+ };
356
377
  const check = () => {
357
- const value = readJson(spec.receipt);
378
+ const value = this.store.record('relays', spec.receipt);
358
379
  if (value) return finish(readReceipt(value));
359
- // not known either way: it may have sent before it ended
360
380
  if (pid && !alive(pid)) finish({ delivered: false, unknown: true, detail: 'the Claude relay process exited before confirming the send' });
361
381
  };
362
- try { watcher = watch(spec.directory, () => check()); watcher.on('error', () => {}); } catch { /* checked on the timer */ }
382
+ const changed = ({ collection, key }) => { if (collection === 'relays' && key === spec.receipt) check(); };
383
+ const cancel = () => finish({ delivered: false, unknown: true, detail: 'the machine daemon stopped before confirming the send' });
384
+ this.receipts.add(cancel);
385
+ this.store.database.on('record', changed);
363
386
  timer = setInterval(check, 500);
364
387
  deadline = setTimeout(() => finish({ delivered: false, unknown: true, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
365
- check();
388
+ if (this.stopped) cancel(); else check();
366
389
  });
367
390
  }
368
391
 
392
+ stop() {
393
+ this.stopped = true;
394
+ for (const timer of this.remoteKept.values()) clearTimeout(timer);
395
+ this.remoteKept.clear();
396
+ for (const cancel of this.receipts) cancel();
397
+ }
398
+
369
399
  /**
370
400
  * Send `message` to the Claude session `receiver` (`{ sessionId, name, socket }`) from `sender` through its relay
371
401
  * (claude_relay.rs `send_through_relay`): the receiver is named by its own socket (`uds:<path>`), unique to it, never
@@ -375,7 +405,8 @@ export class Relays {
375
405
  * relay runtime outlives a daemon that ended mid-send and may still make it. (Not `sending`: that is the field of
376
406
  * sends in progress, which would shadow it.) */
377
407
  queued(sender, senderName) {
378
- try { return Date.now() - statSync(this.spec(sender, senderName).queue).mtimeMs < 2 * SEND_TIMEOUT_MS; } catch { return false; }
408
+ const row = this.store.statement("SELECT updated_at_ms FROM mail_records WHERE collection = 'relays' AND key = ?").get(this.spec(sender, senderName).queue);
409
+ return Boolean(row && Date.now() - row.updated_at_ms < 2 * SEND_TIMEOUT_MS);
379
410
  }
380
411
 
381
412
  /** Whether message `messageId` from `sender` landed in Claude session `sessionId`'s transcript, as a relay send reads
@@ -399,7 +430,8 @@ export class Relays {
399
430
  async #send(spec, receiver, message, messageId, asked, landed) {
400
431
  try {
401
432
  // made under its lock: a retire that held the lock meanwhile may have removed it
402
- mkdirSync(spec.directory, { recursive: true });
433
+ if (this.stopped) return { delivered: false, detail: 'the machine daemon is stopping; no relay turn was submitted' };
434
+ mkdirSync(spec.directory, { recursive: true, mode: 0o700 });
403
435
  const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
404
436
  const steps = [], began = asked;
405
437
  let mark = began;
@@ -407,8 +439,8 @@ export class Relays {
407
439
  step('lock');
408
440
  let { runtime, connection } = await this.#ensure(spec, step);
409
441
  this.#used(spec, connection);
410
- rmSync(spec.receipt, { force: true });
411
- writeFileSync(spec.queue, JSON.stringify(queued));
442
+ this.store.removeRecord('relays', spec.receipt);
443
+ this.store.putRecord('relays', spec.queue, queued);
412
444
  let receipt;
413
445
  for (let attempt = 0; ; attempt++) {
414
446
  // A hand-in whose answer did not come (its bound passed, serve ended mid-call, serve's own deadline on the
@@ -428,20 +460,22 @@ export class Relays {
428
460
  step(`relay still in a turn after ${TURN_END_MS / 1000} s, closed (${error.message})`);
429
461
  ({ runtime, connection } = await this.#ensure(spec, step, { stale: true }));
430
462
  this.#used(spec, connection);
431
- rmSync(spec.receipt, { force: true });
463
+ this.store.removeRecord('relays', spec.receipt);
432
464
  continue;
433
465
  }
434
466
  receipt = { delivered: false, detail: `the Claude relay did not take the send: ${error.message}` };
435
467
  }
436
468
  if (!handed) break;
437
- receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt');
469
+ receipt = await this.#receipt(spec, this.#pid(spec, runtime));
470
+ if (this.stopped) return receipt;
471
+ step('Claude\'s receipt');
438
472
  const pid = this.#pid(spec, runtime);
439
473
  // only a relay proven dead is started again; one alive but slow may still deliver
440
474
  if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
441
475
  try { ({ runtime, connection } = await this.#ensure(spec, step)); this.#used(spec, connection); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
442
- rmSync(spec.receipt, { force: true });
476
+ this.store.removeRecord('relays', spec.receipt);
443
477
  }
444
- rmSync(spec.queue, { force: true });
478
+ this.store.removeRecord('relays', spec.queue);
445
479
  // an outcome not confirmed is read where it would have landed: the relay can no longer send it (its queue is
446
480
  // gone), so the receiver's transcript says whether it did
447
481
  if (!receipt.delivered && receipt.unknown) {
@@ -453,16 +487,16 @@ export class Relays {
453
487
  else receipt = { ...receipt, landed: false };
454
488
  }
455
489
  if (receipt.delivered) {
456
- const sent = readJson(spec.sent) ?? {};
490
+ const sent = this.store.record('relays', spec.sent) ?? {};
457
491
  sent[receiver.sessionId] = messageId;
458
- writeFileSync(spec.sent, JSON.stringify(sent));
492
+ this.store.putRecord('relays', spec.sent, sent);
459
493
  }
460
494
  // a slow relay send says where its time went, in the daemon's log (what a person reads of the connector)
461
495
  if (performance.now() - began > SLOW_SEND_MS) this.log(`relay send to ${receiver.name || receiver.sessionId} took ${Math.round(performance.now() - began)} ms: ${steps.join(', ')}`);
462
496
  return receipt;
463
497
  } catch (error) {
464
- rmSync(spec.queue, { force: true });
465
- return { delivered: false, detail: error.message };
498
+ if (!this.stopped) this.store.removeRecord('relays', spec.queue);
499
+ return { delivered: false, ...(this.stopped ? { unknown: true } : {}), detail: error.message };
466
500
  }
467
501
  }
468
502
 
@@ -474,6 +508,7 @@ export class Relays {
474
508
  async #handIn(connection, queued, step) {
475
509
  const asked = performance.now();
476
510
  for (;;) {
511
+ if (this.stopped) return Object.assign(new Error('the daemon is stopping; no relay turn was submitted'), { notAsked: true });
477
512
  try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); }
478
513
  catch (error) {
479
514
  if (answerLost(error) || !/already in progress/.test(error.message) || performance.now() - asked > TURN_END_MS) return error;
@@ -494,7 +529,7 @@ export class Relays {
494
529
 
495
530
  /** A relay this daemon runs, used now. */
496
531
  #used(spec, connection) {
497
- const runtimeId = readJson(spec.record)?.runtime_id;
532
+ const runtimeId = this.store.record('relays', spec.record)?.runtime_id;
498
533
  if (typeof runtimeId === 'string') this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
499
534
  }
500
535
  }
@@ -0,0 +1,122 @@
1
+ // Migration owns a private snapshot before reading it. A successful import
2
+ // retires only the exact bytes whose database writes completed. Unknown and
3
+ // unreadable sources remain as named diagnostics, never as a read fallback.
4
+ import { createReadStream } from 'node:fs';
5
+ import { lstat, mkdir, readFile, readdir, rename, rmdir, unlink } from 'node:fs/promises';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
+ import { importMaildir } from './import-maildir.mjs';
9
+
10
+ const stat = async (path) => {
11
+ try { return await lstat(path); }
12
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
13
+ };
14
+ const alive = (pid) => {
15
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
16
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
17
+ };
18
+
19
+ async function oldWrappers(root) {
20
+ const dir = join(root, 'native-launches');
21
+ let entries;
22
+ try { entries = await readdir(dir, { withFileTypes: true }); }
23
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
24
+ for (const entry of entries) {
25
+ if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.endsWith('.session.json')) continue;
26
+ const seed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await readFile(join(dir, entry.name))));
27
+ if (alive(seed.ownerPid)) throw new Error(`mail migration is waiting for the older native pane wrapper in ${entry.name} (PID ${seed.ownerPid}); it still writes in the Maildir. Close and relaunch that owned pane through supercode before upgrading this daemon. No pane was closed by migration.`);
28
+ }
29
+ }
30
+
31
+ /** Called only while this daemon holds the mail owner lease. */
32
+ export async function migrateMaildir(database, root, { log = () => {} } = {}) {
33
+ root = resolve(root);
34
+ let retirement = database.metadata('maildir_retirement');
35
+ let source = await stat(root);
36
+ if (!retirement) {
37
+ if (source && !source.isDirectory()) throw new Error(`mail migration requires a regular directory at ${root}`);
38
+ if (source) await oldWrappers(root);
39
+ retirement = { source: root, archive: source ? join(dirname(root), 'mail-retired', randomUUID()) : null, started_at_ms: Date.now(), complete: false };
40
+ database.setMetadata('maildir_retirement', retirement);
41
+ }
42
+ if (retirement.source !== root) throw new Error('the mail migration source differs from its stored receipt');
43
+ if (retirement.complete) {
44
+ if (source) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
45
+ return database.metadata('maildir_import');
46
+ }
47
+ if (!retirement.archive) {
48
+ if (source) throw new Error(`a legacy Maildir appeared after this home's SQLite owner started: ${root}; it is retained, and migration is refused`);
49
+ const imported = await importMaildir(database, root, { log });
50
+ database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), retained_sources: 0 });
51
+ return imported;
52
+ }
53
+ const archiveRoot = resolve(dirname(root), 'mail-retired');
54
+ const archive = resolve(retirement.archive);
55
+ if (dirname(archive) !== archiveRoot) throw new Error('the stored migration snapshot is outside this home');
56
+ let snapshot = await stat(archive);
57
+ if (source) {
58
+ if (snapshot || retirement.complete) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
59
+ if (!source.isDirectory()) throw new Error('mail migration source is no longer a regular directory');
60
+ await oldWrappers(root);
61
+ await mkdir(archiveRoot, { recursive: true, mode: 0o700 });
62
+ await rename(root, archive);
63
+ snapshot = await stat(archive);
64
+ }
65
+ if (!snapshot) {
66
+ if (!retirement.complete) throw new Error('the incomplete mail migration snapshot is missing; originals cannot be reconstructed');
67
+ return database.metadata('maildir_import');
68
+ }
69
+ if (!snapshot.isDirectory()) throw new Error('the migration snapshot is not a regular directory');
70
+ if (retirement.inode != null && (retirement.inode !== snapshot.ino || retirement.device !== snapshot.dev)) throw new Error('the migration snapshot differs from its ownership receipt');
71
+ retirement = { ...retirement, inode: snapshot.ino, device: snapshot.dev };
72
+ database.setMetadata('maildir_retirement', retirement);
73
+ const imported = await importMaildir(database, archive, { log, legacyRoot: root });
74
+ // No ordinary operation becomes ready if an old entrypoint created a second
75
+ // store during import. It is never silently reimported over the first one.
76
+ if (await stat(root)) throw new Error(`a legacy writer recreated ${root} during migration; originals are retained and mail is not activated`);
77
+ const sources = database.statement(`SELECT source, sha256 FROM mail_import_sources s WHERE purged = 0 AND source > ?
78
+ AND NOT EXISTS (SELECT 1 FROM mail_import_errors e WHERE e.source = s.source) ORDER BY source LIMIT 256`);
79
+ const purged = database.statement('UPDATE mail_import_sources SET purged = 1 WHERE source = ?');
80
+ let removed = 0;
81
+ let cursor = '';
82
+ for (;;) {
83
+ const batch = sources.all(cursor);
84
+ if (!batch.length) break;
85
+ const done = [];
86
+ for (const row of batch) {
87
+ const path = resolve(archive, row.source);
88
+ if (isAbsolute(row.source) || !path.startsWith(archive + sep)) throw new Error('an imported source is outside its migration snapshot');
89
+ let parent = dirname(path), regular = true;
90
+ while (parent !== archive) {
91
+ if (!(await stat(parent))?.isDirectory()) { regular = false; break; }
92
+ parent = dirname(parent);
93
+ }
94
+ if (!regular) { log(`mail migration retained linked source ${row.source}`); continue; }
95
+ const file = await stat(path);
96
+ if (!file) { done.push(row.source); continue; }
97
+ if (!file.isFile()) { log(`mail migration retained changed source ${row.source}`); continue; }
98
+ const hash = createHash('sha256');
99
+ for await (const bytes of createReadStream(path)) hash.update(bytes);
100
+ if (hash.digest('hex') !== row.sha256) { log(`mail migration retained changed bytes ${row.source}`); continue; }
101
+ await unlink(path);
102
+ done.push(row.source); removed++;
103
+ }
104
+ // An interruption between unlink and this commit is safe: the next start
105
+ // observes the absent original and records its retirement again.
106
+ database.transaction(() => { for (const source of done) purged.run(source); });
107
+ cursor = batch.at(-1).source;
108
+ }
109
+ let retained = 0;
110
+ async function tidy(dir) {
111
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
112
+ const path = join(dir, entry.name);
113
+ if (entry.isDirectory()) await tidy(path);
114
+ else { retained++; log(`mail migration retained diagnostic source ${relative(archive, path)}`); }
115
+ }
116
+ try { await rmdir(dir); } catch (error) { if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
117
+ }
118
+ await tidy(archive);
119
+ database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), retained_sources: retained });
120
+ log(`mail retired: ${removed} imported source files removed; ${retained} diagnostic sources retained at ${archive}`);
121
+ return imported;
122
+ }