@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.
@@ -0,0 +1,353 @@
1
+ // The machine daemon's mail database. Only its owner opens a writable handle;
2
+ // clients use the machine socket. This module contains no harness or delivery IO.
3
+ import { EventEmitter } from 'node:events';
4
+ import { createRequire } from 'node:module';
5
+ import { chmodSync, closeSync, mkdirSync, openSync } from 'node:fs';
6
+ import { dirname } from 'node:path';
7
+
8
+ export const MAIL_SCHEMA_VERSION = 2;
9
+ export const MAIL_RETENTION = Object.freeze({
10
+ bodyDays: 30,
11
+ maxReadHeaders: 1_000_000,
12
+ maxCarrierRows: 10_000,
13
+ maxTerminalLogRows: 10_000,
14
+ });
15
+ export const RETAINED_BODY = `[this message's body was let go after ${MAIL_RETENTION.bodyDays} days (mail retention); its header and thread are kept]`;
16
+
17
+ const SCHEMA = `
18
+ CREATE TABLE IF NOT EXISTS mail_metadata (
19
+ key TEXT PRIMARY KEY,
20
+ value TEXT NOT NULL
21
+ ) STRICT;
22
+ CREATE TABLE IF NOT EXISTS mailboxes (
23
+ address TEXT PRIMARY KEY,
24
+ folder TEXT NOT NULL UNIQUE
25
+ ) STRICT;
26
+ CREATE TABLE IF NOT EXISTS messages (
27
+ mailbox TEXT NOT NULL REFERENCES mailboxes(address),
28
+ id TEXT NOT NULL,
29
+ thread TEXT NOT NULL,
30
+ sender TEXT NOT NULL,
31
+ kind TEXT NOT NULL,
32
+ subject TEXT,
33
+ created_at_ms INTEGER NOT NULL,
34
+ state TEXT NOT NULL CHECK (state IN ('new', 'claimed', 'cur')),
35
+ header TEXT NOT NULL CHECK (json_valid(header)),
36
+ body TEXT,
37
+ first_line TEXT NOT NULL,
38
+ body_expired_at_ms INTEGER,
39
+ read_at_ms INTEGER,
40
+ claim_pid INTEGER,
41
+ claim_token TEXT,
42
+ PRIMARY KEY (mailbox, id),
43
+ CHECK ((state = 'claimed') = (claim_pid IS NOT NULL AND claim_token IS NOT NULL))
44
+ ) STRICT;
45
+ CREATE INDEX IF NOT EXISTS messages_inbox ON messages(mailbox, state, created_at_ms, id);
46
+ CREATE INDEX IF NOT EXISTS messages_mailbox_time ON messages(mailbox, created_at_ms, id);
47
+ CREATE INDEX IF NOT EXISTS messages_mailbox_kind_time ON messages(mailbox, kind, created_at_ms, id);
48
+ CREATE INDEX IF NOT EXISTS messages_mailbox_thread_time ON messages(mailbox, thread, created_at_ms, id);
49
+ CREATE INDEX IF NOT EXISTS messages_id ON messages(id, mailbox);
50
+ CREATE INDEX IF NOT EXISTS messages_thread ON messages(thread, created_at_ms, id);
51
+ CREATE INDEX IF NOT EXISTS messages_parent ON messages(json_extract(header, '$.in_reply_to'), id, mailbox);
52
+ CREATE INDEX IF NOT EXISTS messages_sender ON messages(sender, created_at_ms, id);
53
+ CREATE INDEX IF NOT EXISTS messages_created ON messages(created_at_ms, sender, kind, id);
54
+ CREATE INDEX IF NOT EXISTS messages_retention ON messages(state, created_at_ms);
55
+ CREATE TABLE IF NOT EXISTS claims (
56
+ mailbox TEXT NOT NULL,
57
+ message TEXT NOT NULL,
58
+ record TEXT NOT NULL CHECK (json_valid(record)),
59
+ PRIMARY KEY (mailbox, message),
60
+ FOREIGN KEY (mailbox, message) REFERENCES messages(mailbox, id) ON DELETE CASCADE
61
+ ) STRICT;
62
+ CREATE TABLE IF NOT EXISTS deliveries (
63
+ mailbox TEXT NOT NULL,
64
+ message TEXT NOT NULL,
65
+ due_at_ms INTEGER NOT NULL,
66
+ attempts INTEGER NOT NULL,
67
+ record TEXT NOT NULL CHECK (json_valid(record)),
68
+ PRIMARY KEY (mailbox, message),
69
+ FOREIGN KEY (mailbox, message) REFERENCES messages(mailbox, id) ON DELETE CASCADE
70
+ ) STRICT;
71
+ CREATE INDEX IF NOT EXISTS deliveries_due ON deliveries(due_at_ms, mailbox, message);
72
+ CREATE TABLE IF NOT EXISTS subscriptions (
73
+ mailbox TEXT NOT NULL,
74
+ message TEXT NOT NULL,
75
+ record TEXT NOT NULL CHECK (json_valid(record)),
76
+ PRIMARY KEY (mailbox, message),
77
+ FOREIGN KEY (mailbox, message) REFERENCES messages(mailbox, id) ON DELETE CASCADE
78
+ ) STRICT;
79
+ CREATE TABLE IF NOT EXISTS mail_records (
80
+ collection TEXT NOT NULL,
81
+ key TEXT NOT NULL,
82
+ record TEXT NOT NULL CHECK (json_valid(record)),
83
+ updated_at_ms INTEGER NOT NULL,
84
+ PRIMARY KEY (collection, key)
85
+ ) STRICT;
86
+ CREATE INDEX IF NOT EXISTS mail_thread_holder
87
+ ON mail_records(json_extract(record, '$.holder')) WHERE collection = 'threads';
88
+ CREATE INDEX IF NOT EXISTS mail_thread_agent
89
+ ON mail_records(json_extract(record, '$.agent')) WHERE collection = 'threads';
90
+ CREATE TABLE IF NOT EXISTS thread_markers (
91
+ collection TEXT NOT NULL DEFAULT 'threads' CHECK (collection = 'threads'),
92
+ thread TEXT NOT NULL,
93
+ marker TEXT NOT NULL,
94
+ message TEXT NOT NULL CHECK (json_valid(message)),
95
+ PRIMARY KEY (thread, marker),
96
+ FOREIGN KEY (collection, thread) REFERENCES mail_records(collection, key) ON DELETE CASCADE
97
+ ) STRICT;
98
+ CREATE INDEX IF NOT EXISTS thread_marker_lookup ON thread_markers(marker, thread);
99
+ CREATE TABLE IF NOT EXISTS sent_messages (
100
+ sender_key TEXT NOT NULL,
101
+ id TEXT NOT NULL,
102
+ recipient TEXT NOT NULL,
103
+ thread TEXT,
104
+ at_ms INTEGER NOT NULL,
105
+ record TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(record) AND json_type(record) = 'object'),
106
+ PRIMARY KEY (sender_key, id, recipient)
107
+ ) STRICT;
108
+ CREATE INDEX IF NOT EXISTS sent_messages_id ON sent_messages(id);
109
+ CREATE TABLE IF NOT EXISTS carrier_log (
110
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ at_ms INTEGER NOT NULL,
112
+ record TEXT NOT NULL CHECK (json_valid(record))
113
+ ) STRICT;
114
+ CREATE TABLE IF NOT EXISTS terminal_log (
115
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT,
116
+ recipient TEXT NOT NULL,
117
+ kind TEXT NOT NULL,
118
+ record TEXT NOT NULL CHECK (json_valid(record))
119
+ ) STRICT;
120
+ CREATE INDEX IF NOT EXISTS terminal_log_recipient ON terminal_log(recipient, sequence);
121
+ CREATE TABLE IF NOT EXISTS mail_import_errors (
122
+ source TEXT PRIMARY KEY,
123
+ mailbox TEXT,
124
+ detail TEXT NOT NULL,
125
+ critical INTEGER NOT NULL DEFAULT 0 CHECK (critical IN (0, 1))
126
+ ) STRICT;
127
+ CREATE TABLE IF NOT EXISTS mail_import_sources (
128
+ source TEXT PRIMARY KEY,
129
+ sha256 TEXT NOT NULL,
130
+ purged INTEGER NOT NULL DEFAULT 0 CHECK (purged IN (0, 1))
131
+ ) STRICT;
132
+ `;
133
+
134
+ function sqlite() {
135
+ const [major, minor] = process.versions.node.split('.').map(Number);
136
+ if (major < 22 || (major === 22 && minor < 13)) throw new Error(`the machine daemon's mail needs Node 22.13.0 or later; this is ${process.version}`);
137
+ return createRequire(import.meta.url)('node:sqlite');
138
+ }
139
+
140
+ /** A separate exclusive SQLite transaction is held for the owner's lifetime.
141
+ * The filesystem releases it on process exit. No PID-file deletion or stale
142
+ * PID guess can admit two writable mail handles for the same home. */
143
+ export class MailOwner {
144
+ constructor(path) {
145
+ const { DatabaseSync } = sqlite();
146
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
147
+ closeSync(openSync(path, 'a', 0o600));
148
+ chmodSync(path, 0o600);
149
+ this.connection = new DatabaseSync(path);
150
+ try {
151
+ this.connection.exec('PRAGMA journal_mode = DELETE; PRAGMA busy_timeout = 0; BEGIN EXCLUSIVE;');
152
+ } catch (error) {
153
+ this.connection.close();
154
+ throw new Error(`mail at ${dirname(path)} already has an owner or cannot acquire its filesystem lock: ${error.message}`);
155
+ }
156
+ }
157
+ close() { this.connection.close(); }
158
+ }
159
+
160
+ /** A synchronous transaction cannot encompass a native-door await. Persist its
161
+ * intent, commit, await the door, then persist the observed result separately. */
162
+ export class MailDatabase extends EventEmitter {
163
+ constructor(path, { retention = MAIL_RETENTION } = {}) {
164
+ super();
165
+ this.path = path;
166
+ this.retention = { ...MAIL_RETENTION, ...retention };
167
+ for (const [name, value] of Object.entries(this.retention)) {
168
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`mail retention ${name} must be a positive integer`);
169
+ }
170
+ // Resolve only when the daemon opens mail. Other CLI doors can still report
171
+ // their own requirements without failing at module import on an older Node.
172
+ const { DatabaseSync } = sqlite();
173
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
174
+ closeSync(openSync(path, 'a', 0o600));
175
+ chmodSync(path, 0o600);
176
+ this.connection = new DatabaseSync(path);
177
+ this.statements = new Map();
178
+ this.depth = 0;
179
+ this.pending = [];
180
+ try {
181
+ let storedVersion = null;
182
+ const tables = this.connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
183
+ if (tables.some(({ name }) => name === 'mail_metadata')) {
184
+ const stored = this.connection.prepare('SELECT value FROM mail_metadata WHERE key = ?').get('schema_version');
185
+ storedVersion = stored ? JSON.parse(stored.value) : null;
186
+ if (storedVersion !== 1 && storedVersion !== MAIL_SCHEMA_VERSION) throw new Error(`mail schema ${storedVersion} is not supported by this release`);
187
+ } else if (tables.length) throw new Error(`the database at ${path} has no mail schema receipt; its schema has not been changed`);
188
+ const mode = this.connection.prepare('PRAGMA journal_mode = WAL').get();
189
+ if (String(mode.journal_mode).toLowerCase() !== 'wal') throw new Error(`mail database cannot use WAL at ${path}`);
190
+ this.connection.exec('PRAGMA synchronous = FULL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 2000;');
191
+ this.transaction(() => {
192
+ this.connection.exec(SCHEMA);
193
+ // The private first candidate stored only the four legacy send fields.
194
+ // Add the complete record before publishing the upgraded schema receipt.
195
+ if (storedVersion === 1 && !this.connection.prepare('PRAGMA table_info(sent_messages)').all().some(({ name }) => name === 'record')) {
196
+ this.connection.exec("ALTER TABLE sent_messages ADD COLUMN record TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(record) AND json_type(record) = 'object');");
197
+ }
198
+ this.setMetadata('schema_version', MAIL_SCHEMA_VERSION);
199
+ });
200
+ } catch (error) {
201
+ this.connection.close();
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ statement(sql) {
207
+ if (this.closed) throw new Error('the mail database is closed; no write or native action is resumed');
208
+ if (!this.statements.has(sql)) this.statements.set(sql, this.connection.prepare(sql));
209
+ return this.statements.get(sql);
210
+ }
211
+
212
+ transaction(operation) {
213
+ if (this.closed) throw new Error('the mail database is closed');
214
+ const depth = this.depth;
215
+ const pending = this.pending.length;
216
+ this.connection.exec(depth ? `SAVEPOINT mail_${depth}` : 'BEGIN IMMEDIATE');
217
+ this.depth++;
218
+ let value;
219
+ try {
220
+ value = operation();
221
+ if (value?.then instanceof Function) throw new Error('mail database transactions must be synchronous');
222
+ this.connection.exec(depth ? `RELEASE mail_${depth}` : 'COMMIT');
223
+ } catch (error) {
224
+ this.pending.length = pending;
225
+ this.connection.exec(depth ? `ROLLBACK TO mail_${depth}; RELEASE mail_${depth}` : 'ROLLBACK');
226
+ throw error;
227
+ } finally {
228
+ this.depth--;
229
+ }
230
+ if (!depth) {
231
+ const events = this.pending.splice(0);
232
+ for (const event of events) this.#notify(event.topic, event.key);
233
+ }
234
+ return value;
235
+ }
236
+
237
+ changed(topic, key) {
238
+ if (!this.listenerCount(topic)) return;
239
+ if (this.depth) this.pending.push({ topic, key });
240
+ else this.#notify(topic, key);
241
+ }
242
+
243
+ #notify(topic, key) {
244
+ // A caller may set the delivery's grace deadline immediately after creating
245
+ // its wake. Notify after that write, as the former filesystem event did.
246
+ setImmediate(() => { if (!this.closed) this.emit(topic, key); });
247
+ }
248
+
249
+ metadata(key) {
250
+ const found = this.statement('SELECT value FROM mail_metadata WHERE key = ?').get(key);
251
+ return found ? JSON.parse(found.value) : null;
252
+ }
253
+
254
+ setMetadata(key, value) {
255
+ this.statement('INSERT INTO mail_metadata(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, JSON.stringify(value));
256
+ }
257
+
258
+ record(collection, key) {
259
+ const row = this.statement('SELECT record FROM mail_records WHERE collection = ? AND key = ?').get(collection, key);
260
+ return row ? JSON.parse(row.record) : null;
261
+ }
262
+
263
+ records(collection) {
264
+ return this.statement('SELECT key, record, updated_at_ms FROM mail_records WHERE collection = ? ORDER BY key').all(collection)
265
+ .map((row) => ({ key: row.key, value: JSON.parse(row.record), updated_at_ms: row.updated_at_ms }));
266
+ }
267
+
268
+ putRecord(collection, key, value, { create = false, at = Date.now() } = {}) {
269
+ const sql = create
270
+ ? 'INSERT INTO mail_records(collection, key, record, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(collection, key) DO NOTHING'
271
+ : 'INSERT INTO mail_records(collection, key, record, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(collection, key) DO UPDATE SET record = excluded.record, updated_at_ms = excluded.updated_at_ms';
272
+ return this.transaction(() => {
273
+ const changed = this.statement(sql).run(collection, key, JSON.stringify(value), at).changes > 0;
274
+ if (changed && collection === 'threads') {
275
+ this.statement('DELETE FROM thread_markers WHERE thread = ?').run(key);
276
+ const add = this.statement('INSERT INTO thread_markers(thread, marker, message) VALUES (?, ?, ?)');
277
+ for (const [marker, message] of Object.entries(value.markers ?? {})) add.run(key, marker, JSON.stringify(message));
278
+ }
279
+ if (changed) this.changed('record', { collection, key });
280
+ return changed;
281
+ });
282
+ }
283
+
284
+ removeRecord(collection, key) {
285
+ const changed = this.statement('DELETE FROM mail_records WHERE collection = ? AND key = ?').run(collection, key).changes > 0;
286
+ if (changed) this.changed('record', { collection, key });
287
+ return changed;
288
+ }
289
+
290
+ appendCarrier(record) {
291
+ return this.transaction(() => {
292
+ this.statement('INSERT INTO carrier_log(at_ms, record) VALUES (?, ?)').run(Date.now(), JSON.stringify(record));
293
+ this.statement('DELETE FROM carrier_log WHERE sequence <= (SELECT sequence FROM carrier_log ORDER BY sequence DESC LIMIT 1 OFFSET ?)').run(this.retention.maxCarrierRows);
294
+ });
295
+ }
296
+
297
+ /** Only settled read mail is eligible. Pending deliveries, subscriptions and
298
+ * claim holders protect their originals; an unresolved message never loses its body. */
299
+ retain(now = Date.now()) {
300
+ const cutoff = now - this.retention.bodyDays * 86_400_000;
301
+ return this.transaction(() => {
302
+ const expired = this.statement(`UPDATE messages SET body = NULL, first_line = '', body_expired_at_ms = ?
303
+ WHERE state = 'cur' AND created_at_ms < ? AND body IS NOT NULL
304
+ AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = messages.mailbox AND d.message = messages.id)
305
+ AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = messages.mailbox AND s.message = messages.id)`)
306
+ .run(now, cutoff).changes;
307
+ // Completed native questions duplicate an envelope and its answer input.
308
+ // Keep the claim/binding/header so a repeat cannot submit another answer,
309
+ // but let go of those text copies after the mailbox copies have expired.
310
+ const questions = this.statement(`UPDATE mail_records AS r SET record = json_set(
311
+ json_remove(record, '$.native', '$.answers'), '$.envelope.body', ?, '$.body_expired_at_ms', ?)
312
+ WHERE collection = 'native_questions' AND json_type(record, '$.envelope') = 'object'
313
+ AND json_type(record, '$.body_expired_at_ms') IS NULL
314
+ AND json_extract(record, '$.envelope.created_at_ms') < ?
315
+ AND EXISTS (SELECT 1 FROM mail_records d WHERE d.collection = 'native_questions'
316
+ AND d.key = substr(r.key, 1, instr(r.key, '.') - 1) || '.delivered' AND CAST(d.record AS INTEGER) < ?)
317
+ AND NOT EXISTS (SELECT 1 FROM mail_records w WHERE w.collection = 'native_questions'
318
+ AND w.key = substr(r.key, 1, instr(r.key, '.') - 1) || '.waiting')
319
+ AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.id = json_extract(r.record, '$.envelope.id') AND m.body IS NOT NULL)`)
320
+ .run(RETAINED_BODY, now, cutoff, cutoff).changes;
321
+ const relayRecords = this.statement(`DELETE FROM mail_records AS r WHERE collection = 'relays'
322
+ AND (key LIKE '%/turn.txt' OR key LIKE '%/receipt.json') AND updated_at_ms < ?
323
+ AND NOT EXISTS (SELECT 1 FROM mail_records q WHERE q.collection = 'relays'
324
+ AND q.key = substr(r.key, 1, instr(r.key, '/')) || 'queue.json')`).run(cutoff).changes;
325
+ const launchInputs = this.statement(`UPDATE mail_records SET record = json_set(
326
+ json_remove(record, '$.initialInput', '$.claudeInput.text'), '$.input_body_expired_at_ms', ?)
327
+ WHERE collection = 'native_launches' AND json_type(record, '$.input_body_expired_at_ms') IS NULL
328
+ AND json_type(record, '$.input_settled_at_ms') = 'integer'
329
+ AND json_extract(record, '$.input_settled_at_ms') < ?
330
+ AND (json_type(record, '$.initialInput') = 'text' OR json_type(record, '$.claudeInput.text') = 'text')`)
331
+ .run(now, cutoff).changes;
332
+ const removed = this.statement(`DELETE FROM messages WHERE (mailbox, id) IN (
333
+ SELECT mailbox, id FROM messages m WHERE state = 'cur' AND body_expired_at_ms IS NOT NULL
334
+ AND NOT EXISTS (SELECT 1 FROM deliveries d WHERE d.mailbox = m.mailbox AND d.message = m.id)
335
+ AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.mailbox = m.mailbox AND s.message = m.id)
336
+ ORDER BY created_at_ms DESC, id DESC LIMIT -1 OFFSET ?)`)
337
+ .run(this.retention.maxReadHeaders).changes;
338
+ const result = { at_ms: now, expired_bodies: expired, expired_question_records: questions,
339
+ removed_relay_text_records: relayRecords, expired_launch_inputs: launchInputs, removed_headers: removed, policy: this.retention };
340
+ this.setMetadata('retention', result);
341
+ if (expired || questions || relayRecords || launchInputs || removed) this.changed('retention', result);
342
+ return result;
343
+ });
344
+ }
345
+
346
+ close() {
347
+ if (this.closed) return;
348
+ this.closed = true;
349
+ this.removeAllListeners();
350
+ this.statements.clear();
351
+ this.connection.close();
352
+ }
353
+ }
@@ -2,7 +2,7 @@
2
2
  // lines its own user typed, the lines it took from its harness's own channels, its answers, and when each filed message
3
3
  // first reached the model. The mailbox records a message as sent; only the transcript records it delivered. A transcript
4
4
  // is read only when a call names the message or the session it is about (one named read, never a sweep).
5
- import { readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { readdirSync, statSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { blake3Hex } from './envelope.mjs';
@@ -234,10 +234,10 @@ export async function readCodex(path) {
234
234
  }
235
235
 
236
236
  /** The transcript of the session at `address` on this machine, when its harness's records are read here. */
237
- export function transcriptPath(address, { claudeProjects, mailRoot }) {
237
+ export function transcriptPath(address, { claudeProjects, store }) {
238
238
  const [, , harness, sessionId] = String(address).split(':');
239
239
  if (harness === 'codex') {
240
- try { return JSON.parse(readFileSync(join(mailRoot, 'native-questions', `binding-${blake3Hex(address)}.json`), 'utf8')).transcript_path ?? null; }
240
+ try { return store.record('native_questions', `binding-${blake3Hex(address)}.json`)?.transcript_path ?? null; }
241
241
  catch { return null; }
242
242
  }
243
243
  if (harness !== 'claude-code') return null;
package/mail/upkeep.mjs CHANGED
@@ -9,32 +9,32 @@
9
9
  // The first and the last follow the live map's changes; the copying passes every TYPED_EVERY_MS while any agent is
10
10
  // declared, reading only a transcript written since its cursor.
11
11
  import { spawn } from 'node:child_process';
12
- import { readFileSync, statSync } from 'node:fs';
13
- import { join } from 'node:path';
12
+ import { statSync } from 'node:fs';
14
13
  import { blake3Hex } from './envelope.mjs';
15
14
  import { envelope as makeEnvelope, makeAddress } from './compose.mjs';
16
15
  import { TYPED_ID_PREFIX, readClaude, transcriptPath, typedLineId } from './transcript.mjs';
17
- import { writeAtomically } from './agents.mjs';
18
16
 
19
17
  const SESSION_THREAD_PREFIX = 's-';
20
18
  const TYPED_EVERY_MS = 10_000;
19
+ const MAX_COPY_BATCH = 256;
20
+ const yieldTurn = () => new Promise((resolve) => setImmediate(resolve));
21
21
 
22
22
  export class AgentUpkeep {
23
23
  /** `agents` the agents' records; `live` the live-session map; `lastMessageAt(address)` when a session last spoke (its
24
24
  * index row); `program` the supercode a session is ended with. */
25
25
  constructor({ machine, mailRoot, store, agents, live, claudeProjects, lastMessageAt, program, env, log = () => {} }) {
26
26
  Object.assign(this, { machine, mailRoot, store, agents, live, claudeProjects, lastMessageAt, program, env, log });
27
- this.typedDir = join(mailRoot, 'agents', 'typed');
27
+ this.stopped = false;
28
28
  this.running = new Set();
29
29
  }
30
30
 
31
31
  start() {
32
- let pending = null;
33
- this.live.on('changed', () => {
34
- if (pending) return;
35
- pending = setTimeout(() => { pending = null; void this.#once('sessions', () => this.registerFolderSessions().then(() => this.closeIdleThreads())); }, 1000);
36
- pending.unref?.();
37
- });
32
+ this.onLive = () => {
33
+ if (this.stopped || this.pending) return;
34
+ this.pending = setTimeout(() => { this.pending = null; void this.#once('sessions', () => this.registerFolderSessions().then(() => this.closeIdleThreads())); }, 1000);
35
+ this.pending.unref?.();
36
+ };
37
+ this.live.on('changed', this.onLive);
38
38
  this.timer = setInterval(() => void this.#once('typed', () => this.copyTypedLines()), TYPED_EVERY_MS);
39
39
  this.timer.unref?.();
40
40
  // an idle thread's bound passes with no change to the map: checked once a minute as well
@@ -43,10 +43,10 @@ export class AgentUpkeep {
43
43
  return this;
44
44
  }
45
45
 
46
- stop() { clearInterval(this.timer); clearInterval(this.idleTimer); }
46
+ stop() { this.stopped = true; this.live.off('changed', this.onLive); clearTimeout(this.pending); clearInterval(this.timer); clearInterval(this.idleTimer); }
47
47
 
48
48
  async #once(key, work) {
49
- if (this.running.has(key)) return;
49
+ if (this.stopped || this.running.has(key)) return;
50
50
  this.running.add(key);
51
51
  try { await work(); } catch (error) { this.log(`agent upkeep: ${key}: ${error.message}`); } finally { this.running.delete(key); }
52
52
  }
@@ -58,6 +58,7 @@ export class AgentUpkeep {
58
58
  if (!agents.length) return;
59
59
  const holders = new Set(this.agents.threads().map((thread) => thread.holder));
60
60
  for (const session of this.live.sessions()) {
61
+ if (this.stopped) return;
61
62
  if (!session.cwd) continue;
62
63
  const agent = agents.find((item) => item.folder === session.cwd && item.main_session !== session.address);
63
64
  if (!agent || holders.has(session.address)) continue;
@@ -69,7 +70,7 @@ export class AgentUpkeep {
69
70
  }
70
71
  }
71
72
 
72
- #cursorPath(address) { return join(this.typedDir, blake3Hex(address).slice(0, 24)); }
73
+ #cursorKey(address) { return blake3Hex(address).slice(0, 24); }
73
74
 
74
75
  /** Each line typed into an agent's session since the last pass, copied to who is CC on it; a session seen the first
75
76
  * time starts from now, so its history is not copied. */
@@ -82,39 +83,58 @@ export class AgentUpkeep {
82
83
  for (const thread of threads) if (!sessions.some(([address]) => address === thread.holder)) sessions.push([thread.holder, thread.agent]);
83
84
  const now = Date.now();
84
85
  for (const [session, agentName] of sessions) {
85
- const cursorPath = this.#cursorPath(session);
86
- let cursor = null;
87
- try { cursor = Number(readFileSync(cursorPath, 'utf8').trim()); } catch { /* first seen */ }
88
- if (!Number.isFinite(cursor)) { writeAtomically(cursorPath, String(now)); continue; }
86
+ if (this.stopped) return;
87
+ const cursorKey = this.#cursorKey(session);
88
+ const previous = this.store.record('agent_typed', cursorKey);
89
+ const cursor = previous == null ? null : Number(previous);
90
+ if (!Number.isFinite(cursor)) { this.store.putRecord('agent_typed', cursorKey, String(now)); continue; }
89
91
  // a transcript not written since the last pass has no new line
90
- const path = transcriptPath(session, { claudeProjects: this.claudeProjects(), mailRoot: this.mailRoot });
92
+ const path = transcriptPath(session, { claudeProjects: this.claudeProjects(), store: this.store });
91
93
  if (path) { try { if (statSync(path).mtimeMs <= cursor) continue; } catch { /* not there: nothing new */ } }
92
94
  // a Claude session's lines are read from its transcript; a Codex session's were filed in its mailbox by its hook
93
95
  let lines;
94
96
  if (session.split(':')[2] === 'codex') {
95
- lines = this.store.inventory(session).stored
96
- .filter((item) => item.envelope.kind === 'user' && item.envelope.id.startsWith(TYPED_ID_PREFIX) && item.envelope.created_at_ms > cursor)
97
+ lines = this.store.typedSince(session, cursor, TYPED_ID_PREFIX)
97
98
  .map((item) => { const whole = this.store.body(item); return [item.envelope.created_at_ms, whole?.body ?? '']; });
98
99
  } else {
99
100
  if (!path) continue;
100
101
  const mail = await readClaude(path).catch(() => null);
102
+ if (this.stopped) return;
101
103
  if (!mail) continue;
102
104
  lines = mail.typed.filter((line) => !line.withdrawn && line.sent_at_ms > cursor).map((line) => [line.sent_at_ms, line.text]);
103
105
  }
104
106
  if (!lines.length) continue;
105
- const newest = Math.max(...lines.map(([at]) => at));
107
+ let newest = cursor;
108
+ for (const [at] of lines) newest = Math.max(newest, at);
106
109
  // the newest thread this session holds as a delegate (its agent's main session is another: mail_agent.rs `delegated`)
107
110
  const held = [...threads].reverse().find((thread) => thread.holder === session && this.agents.load(thread.agent)?.main_session !== thread.holder);
108
111
  const receivers = held ? held.participants.filter((p) => p.role === 'cc' && p.address !== session).map((p) => p.address) : [];
109
112
  if (accountManager && agentName !== accountManager.name && !receivers.includes(accountManager.main_session)) receivers.push(accountManager.main_session);
110
113
  const from = makeAddress(session.split(':')[1], 'operator', 'typed');
114
+ let copies = [];
115
+ const flush = (complete = false) => {
116
+ this.store.transaction(() => {
117
+ for (const [receiver, copy] of copies) this.store.deliver(receiver, copy);
118
+ // Partial batches retain the old cursor. Stable ids make their
119
+ // committed copies harmless to repeat after a failed later batch.
120
+ if (complete) this.store.putRecord('agent_typed', cursorKey, String(newest));
121
+ });
122
+ copies = [];
123
+ };
111
124
  for (const [sentAt, text] of lines) {
125
+ if (this.stopped) return;
112
126
  const copy = makeEnvelope({ id: typedLineId(session, sentAt, text), created_at_ms: sentAt, from, from_name: `typed into ${session} (unattributed)`,
113
127
  kind: 'typed', reply_via: { mode: 'none' }, thread: held?.id ?? null, body: text });
114
- // a copy that cannot be filed is said, never dropped in silence
115
- for (const receiver of receivers) { try { this.store.deliver(receiver, copy); } catch (error) { this.log(`agent upkeep: the line typed into ${session} was not copied to ${receiver}: ${error.message}`); } }
128
+ for (const receiver of receivers) {
129
+ copies.push([receiver, copy]);
130
+ if (copies.length === MAX_COPY_BATCH) {
131
+ flush();
132
+ await yieldTurn();
133
+ if (this.stopped) return;
134
+ }
135
+ }
116
136
  }
117
- writeAtomically(cursorPath, String(newest));
137
+ flush(true);
118
138
  }
119
139
  }
120
140
 
@@ -127,12 +147,12 @@ export class AgentUpkeep {
127
147
  const live = this.live.sessions();
128
148
  const now = Date.now();
129
149
  for (const [thread, minutes] of launched) {
150
+ if (this.stopped) return;
130
151
  const session = live.find((row) => row.address === thread.holder);
131
152
  if (!session || session.status !== 'idle') continue;
132
153
  const last = this.lastMessageAt(thread.holder);
133
154
  if (!Number.isFinite(last)) continue;
134
- let resumed = 0;
135
- try { resumed = Number(readFileSync(join(this.mailRoot, 'agents', 'resumed', blake3Hex(thread.holder).slice(0, 24)), 'utf8').trim()) || 0; } catch { /* never resumed */ }
155
+ const resumed = Number(this.store.record('agent_resumed', blake3Hex(thread.holder).slice(0, 24))) || 0;
136
156
  if (now - Math.max(last, resumed) < minutes * 60_000) continue;
137
157
  await new Promise((done) => {
138
158
  const child = spawn(this.program, ['close', thread.holder], { env: this.env, stdio: 'ignore' });
package/mail/verbs.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // An agent's verbs (docs/architecture/overview.md, "The mail operations", 3b-2): reading a conversation (`message
2
2
  // thread`), listing an agent's threads (`message threads`), handing a root to a new session of the agent (`message
3
3
  // delegate`), and declaring or showing an agent (`supercode agent declare|show`), answered by the daemon as
4
- // crates/cli/src/message.rs answered them, their texts kept. Each read answers from the header index; a body is read from
5
- // its one file when its message is shown.
4
+ // crates/cli/src/message.rs answered them, their texts kept. Each read answers from indexed headers; a body is read from
5
+ // its database row when its message is shown.
6
6
  import { spawn } from 'node:child_process';
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import { realpathSync } from 'node:fs';
@@ -33,42 +33,15 @@ export class AgentVerbs {
33
33
  * Answers `{ code, rows, rendered }` or `{ code, text }`.
34
34
  */
35
35
  thread(id) {
36
- const all = new Map(); // id → { address, stored }
37
- for (const address of this.store.addresses()) {
38
- for (const stored of this.store.inventory(address).stored) if (!all.has(stored.envelope.id)) all.set(stored.envelope.id, { address, stored });
39
- }
40
- const matches = [...all.keys()].filter((key) => key.startsWith(id));
41
- if (matches.length !== 1) return { code: EXIT_UNKNOWN, text: `${id} names ${matches.length} messages here; give one message's id.` };
42
- // up to the root: the oldest ancestor filed here
43
- let root = matches[0];
44
- const seen = new Set();
45
- for (;;) {
46
- const parent = all.get(root)?.stored.envelope.in_reply_to;
47
- if (!parent || !all.has(parent) || seen.has(parent)) break;
48
- seen.add(parent); root = parent;
49
- }
50
- const threadOfRoot = threadOf(all.get(root).stored.envelope);
51
- // down from the root: every reply, and every message naming the same thread
52
- const children = new Map();
53
- for (const [key, { stored }] of all) {
54
- const parent = stored.envelope.in_reply_to;
55
- if (parent) children.set(parent, [...(children.get(parent) ?? []), key]);
56
- }
57
- const members = new Set();
58
- const queue = [root, ...[...all].filter(([, { stored }]) => stored.envelope.thread === threadOfRoot).map(([key]) => key)];
59
- while (queue.length) {
60
- const next = queue.pop();
61
- if (members.has(next)) continue;
62
- members.add(next);
63
- queue.push(...(children.get(next) ?? []));
64
- }
65
- const conversation = [...members].map((key) => all.get(key)).map(({ address, stored }) => ({ address, stored: this.#whole(stored) })).filter((item) => item.stored)
36
+ const { matches, messages } = this.store.conversation(id);
37
+ if (matches !== 1) return { code: EXIT_UNKNOWN, text: `${id} names ${matches} messages here; give one message's id.` };
38
+ const conversation = messages.map(({ address, stored }) => ({ address, stored: this.#whole(stored) })).filter((item) => item.stored)
66
39
  .sort((a, b) => a.stored.envelope.created_at_ms - b.stored.envelope.created_at_ms);
67
40
  const deliveries = new Map();
68
41
  return {
69
42
  code: 0,
70
- rows: conversation.map(({ address, stored }) => messageRow(address, stored, deliveries, this.mailRoot)),
71
- rendered: conversation.map(({ address, stored }) => `To ${address}:\n${listed(stored.envelope, deliveries, this.mailRoot)}\n`),
43
+ rows: conversation.map(({ address, stored }) => messageRow(address, stored, deliveries, this.store)),
44
+ rendered: conversation.map(({ address, stored }) => `To ${address}:\n${listed(stored.envelope, deliveries, this.store)}\n`),
72
45
  };
73
46
  }
74
47
 
@@ -104,6 +77,7 @@ export class AgentVerbs {
104
77
  * found in the live-session map by the pane it opened in, within a minute.
105
78
  */
106
79
  async openSession(program, directory, key, input, ready) {
80
+ if (this.stopped) throw new Error('the daemon is stopping; no agent session was opened');
107
81
  const stem = String(program).split(/[\\/]/).pop().replace(/\.[^.]*$/, '');
108
82
  const claude = stem === 'claude' || stem === 'claude-code';
109
83
  let known = null;
@@ -117,6 +91,7 @@ export class AgentVerbs {
117
91
  extra.push('--', '--session-id', id);
118
92
  await ready(known);
119
93
  }
94
+ if (this.stopped) throw new Error('the daemon is stopping; no agent session was opened');
120
95
  const run = await new Promise((resolve) => {
121
96
  const child = spawn(this.supercodeBin, ['--cwd', directory, 'open', '--new', program, '--key', key, '--input', input, '--detach', ...extra], { stdio: ['ignore', 'pipe', 'pipe'], env: this.env });
122
97
  let out = '', err = '';
@@ -136,18 +111,22 @@ export class AgentVerbs {
136
111
  try {
137
112
  const started = Date.now();
138
113
  while (Date.now() - started < 60_000) {
114
+ if (this.stopped) throw new Error(`the daemon stopped while opening pane ${pane}; its launch is unconfirmed`);
139
115
  const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
140
116
  if (session) { await ready(session.address); return session.address; }
141
117
  await sleep(500);
142
118
  }
143
119
  throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
144
120
  } catch (error) {
121
+ if (this.stopped) throw error;
145
122
  if (reused) { error.message = error.message.replace('; its pane is closed', `; its pane ${pane} was opened before, and is left open`); throw error; }
146
123
  try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
147
124
  throw error;
148
125
  }
149
126
  }
150
127
 
128
+ stop() { this.stopped = true; }
129
+
151
130
  /**
152
131
  * Hand a root the caller received as an agent's main session to a new session of the agent (message.rs `delegate`):
153
132
  * it opens in a pane with the root, holds the thread (every reply wakes it), and main stays CC. Answers `{ code, text }`.
@@ -170,8 +149,7 @@ export class AgentVerbs {
170
149
  const input = `You are a session of agent ${agent.name}, holding its thread ${shownRoot}. Read it: supercode message thread ${shownRoot}. Answer in it with supercode message reply <id>; what you send stays in this thread, and ${agent.name}'s main session is CC on it.`;
171
150
  // the new session holds the thread and has its messages before it starts, so its first turn finds its root
172
151
  const handOver = async (holder) => {
173
- for (const line of this.store.inventory(caller).stored) {
174
- if (threadOf(line.envelope) !== thread.id) continue;
152
+ for (const line of this.store.threadIn(caller, thread.id)) {
175
153
  const whole = this.#whole(line);
176
154
  if (!whole) continue;
177
155
  // a line typed into main's terminal reaches the holder as one typed in another session (`typed`, ADR 0008)