@volter/supercode-teams 0.3.150 → 0.3.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +143 -46
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- package/mail/continuation.mjs +101 -0
- package/mail/envelope.mjs +8 -10
- package/mail/hooks.mjs +43 -27
- package/mail/idle.mjs +14 -5
- package/mail/import-maildir.mjs +350 -0
- package/mail/questions.mjs +38 -46
- package/mail/reads.mjs +15 -26
- package/mail/relay-endpoint.mjs +14 -22
- package/mail/relay.mjs +100 -65
- package/mail/retire-maildir.mjs +122 -0
- package/mail/send.mjs +141 -104
- package/mail/sqlite-store.mjs +476 -0
- package/mail/sqlite.mjs +353 -0
- package/mail/transcript.mjs +3 -3
- package/mail/upkeep.mjs +46 -26
- package/mail/verbs.mjs +14 -36
- package/native-codex-pane.mjs +6 -6
- package/native-initial-input.mjs +6 -6
- package/native-launch-binding.mjs +35 -0
- package/package.json +2 -2
- package/session-people.mjs +7 -8
- package/terminal-mail.mjs +8 -19
- package/mail/store.mjs +0 -531
package/mail/sqlite.mjs
ADDED
|
@@ -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
|
+
}
|
package/mail/transcript.mjs
CHANGED
|
@@ -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 {
|
|
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,
|
|
237
|
+
export function transcriptPath(address, { claudeProjects, store }) {
|
|
238
238
|
const [, , harness, sessionId] = String(address).split(':');
|
|
239
239
|
if (harness === 'codex') {
|
|
240
|
-
try { return
|
|
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 {
|
|
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.
|
|
27
|
+
this.stopped = false;
|
|
28
28
|
this.running = new Set();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
start() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(),
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
// its
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
71
|
-
rendered: conversation.map(({ address, stored }) => `To ${address}:\n${listed(stored.envelope, deliveries, this.
|
|
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.
|
|
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)
|