@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.
- package/README.md +14 -1
- package/bin/teams.mjs +10 -2
- package/machine.mjs +128 -43
- package/mail/agents.mjs +40 -119
- package/mail/carrier.mjs +72 -111
- 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 +65 -81
- 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/relay-endpoint.mjs
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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 `
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
76
|
-
if (!
|
|
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 =
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
if (this.stopped) return false;
|
|
158
158
|
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
159
|
-
const record =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
235
|
-
record:
|
|
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 =
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
//
|
|
290
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
328
|
-
//
|
|
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
|
-
/**
|
|
351
|
-
*
|
|
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,
|
|
355
|
-
const finish = (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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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));
|
|
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
|
-
|
|
476
|
+
this.store.removeRecord('relays', spec.receipt);
|
|
443
477
|
}
|
|
444
|
-
|
|
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 =
|
|
490
|
+
const sent = this.store.record('relays', spec.sent) ?? {};
|
|
457
491
|
sent[receiver.sessionId] = messageId;
|
|
458
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|