@volter/supercode-teams 0.3.96 → 0.3.97
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/live-sessions.mjs +29 -3
- package/machine.mjs +21 -0
- package/mail/relay-endpoint.mjs +120 -0
- package/mail/relay.mjs +87 -12
- package/package.json +1 -1
package/live-sessions.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// - Messaging tools: the marker a `message mcp` server writes for its session process, with the server alive.
|
|
15
15
|
import { EventEmitter } from 'node:events';
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, watch, writeFileSync } from 'node:fs';
|
|
17
|
+
import { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, watch, writeFileSync } from 'node:fs';
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { dirname, join } from 'node:path';
|
|
20
20
|
import { FrontendClient } from '@volter/supercode-frontend';
|
|
@@ -53,6 +53,15 @@ function linuxStart(pid) {
|
|
|
53
53
|
} catch { return null; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** The parent of the live process `pid` (Linux's /proc, or one `ps` on macOS), or null when it cannot be read. */
|
|
57
|
+
function parentOf(pid) {
|
|
58
|
+
if (process.platform === 'linux') {
|
|
59
|
+
try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); return Promise.resolve(Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]) || null); } catch { return Promise.resolve(null); }
|
|
60
|
+
}
|
|
61
|
+
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
62
|
+
return new Promise((resolve) => execFile('ps', ['-o', 'ppid=', '-p', String(pid)], { timeout: 10_000 }, (error, stdout) => resolve(error ? null : Number(String(stdout).trim()) || null)));
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
/** `path` with its symlinks resolved, or null when it cannot be (gone, or not a string). */
|
|
57
66
|
function realPath(path) { try { return typeof path === 'string' ? realpathSync(path) : null; } catch { return null; } }
|
|
58
67
|
|
|
@@ -210,7 +219,12 @@ export class LiveSessions extends EventEmitter {
|
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
/** A Claude process's entry let go, with what was read of its process's start (a new process may take its pid). */
|
|
213
|
-
#dropClaude(pid) {
|
|
222
|
+
#dropClaude(pid) {
|
|
223
|
+
this.claude.delete(pid);
|
|
224
|
+
// its messaging tools' marker goes with it when it has ended (step 4a: by its end, never a minute's sweep); an entry
|
|
225
|
+
// dropped for another reason (its home no longer declared, a parked process) keeps its marker for its return
|
|
226
|
+
if (this.markers.has(pid) && !alive(pid)) { this.markers.delete(pid); try { unlinkSync(join(this.markersDir, String(pid))); } catch { /* gone */ } }
|
|
227
|
+
}
|
|
214
228
|
|
|
215
229
|
/** Every held process asked whether it is alive (signal 0): a dead one's entry goes. */
|
|
216
230
|
#sweepDead() {
|
|
@@ -365,9 +379,21 @@ export class LiveSessions extends EventEmitter {
|
|
|
365
379
|
if (!Number.isInteger(pid)) return;
|
|
366
380
|
let server = null;
|
|
367
381
|
try { server = Number(readFileSync(join(this.markersDir, name), 'utf8').trim()); } catch { /* removed */ }
|
|
368
|
-
|
|
382
|
+
// a marker whose server has ended names no tools: removed (read at start, and as each is written or changed)
|
|
383
|
+
if (Number.isInteger(server) && server > 0 && !alive(server)) { try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ } server = null; }
|
|
384
|
+
if (!(Number.isInteger(server) && server > 0)) { if (this.markers.delete(pid)) this.#changed(); return; }
|
|
385
|
+
this.markers.set(pid, server);
|
|
386
|
+
// its server is the session's own child (a harness starts its MCP servers so), checked once per marker: a server pid
|
|
387
|
+
// used again by another process names no tools (mail_route.rs `has_message_tools` read it so)
|
|
388
|
+
void parentOf(server).then((parent) => {
|
|
389
|
+
if (parent == null || parent === pid || this.markers.get(pid) !== server) return;
|
|
390
|
+
this.markers.delete(pid);
|
|
391
|
+
try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ }
|
|
392
|
+
this.#changed();
|
|
393
|
+
});
|
|
369
394
|
}
|
|
370
395
|
|
|
396
|
+
|
|
371
397
|
#readUserHook() {
|
|
372
398
|
let text = '';
|
|
373
399
|
try { text = readFileSync(this.codexHooks, 'utf8'); } catch { /* none */ }
|
package/machine.mjs
CHANGED
|
@@ -39,6 +39,7 @@ import { MailReads, panePrompt } from './mail/reads.mjs';
|
|
|
39
39
|
import { render } from './mail/envelope.mjs';
|
|
40
40
|
import { Agents } from './mail/agents.mjs';
|
|
41
41
|
import { Relays } from './mail/relay.mjs';
|
|
42
|
+
import { serveRelayEndpoint } from './mail/relay-endpoint.mjs';
|
|
42
43
|
import { MailSend } from './mail/send.mjs';
|
|
43
44
|
import { AgentVerbs } from './mail/verbs.mjs';
|
|
44
45
|
import { deliverToRuntime } from './mail/runtime.mjs';
|
|
@@ -653,6 +654,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
653
654
|
// ---- this machine's mail (mail/; docs/architecture/overview.md, "The mail operations") ---------------------------
|
|
654
655
|
#startMail() {
|
|
655
656
|
const mailRoot = join(supercodeHome(this.env), 'mail');
|
|
657
|
+
// the Messages API a Claude relay talks to (step 4a: the daemon's, in process; the native watch served it)
|
|
658
|
+
this.relayEndpoint = serveRelayEndpoint(mailRoot, { log: (line) => this.emit('mail-log', line) })
|
|
659
|
+
.catch((error) => { this.emit('mail-log', `relay endpoint: not served (${error.message}); a Claude session cannot be sent mail until the daemon starts again`); return null; });
|
|
656
660
|
this.mailStore = new MailStore({ mailRoot, machine: machineName(this.env), log: (line) => this.emit('records-log', line) }).start();
|
|
657
661
|
// the session index's rows by address (its descriptors, as the records' index subscription hands them over)
|
|
658
662
|
this.sessionDescriptors = new Map();
|
|
@@ -678,6 +682,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
678
682
|
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
679
683
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
680
684
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
685
|
+
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
681
686
|
log: (line) => this.emit('mail-log', line) });
|
|
682
687
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
683
688
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
@@ -700,6 +705,21 @@ export class TeamsMachine extends MachineCore {
|
|
|
700
705
|
// the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
|
|
701
706
|
// runs under another, kept from the map's changes (at most once a second)
|
|
702
707
|
let naming = null;
|
|
708
|
+
// a session that has ended has its relay retired (step 4a: by its end, never a minute's sweep); the relays of
|
|
709
|
+
// sessions that ended while no daemon ran are retired once, at start, when the live sessions are known
|
|
710
|
+
let wasLive = new Map(live.sessions().map((row) => [row.address, row.name]));
|
|
711
|
+
live.on('changed', () => {
|
|
712
|
+
// while the Codex sessions are not known (a start, a serve restart) a Codex session missing has not ended: the last
|
|
713
|
+
// known view stands until they are known again
|
|
714
|
+
if (!live.codexKnown) return;
|
|
715
|
+
const now = new Map(live.sessions().map((row) => [row.address, row.name]));
|
|
716
|
+
for (const [address, name] of wasLive) if (!now.has(address)) void this.relays.retire(address, name).catch(() => {});
|
|
717
|
+
wasLive = now;
|
|
718
|
+
});
|
|
719
|
+
void live.whenCodexKnown(120_000).then((known) => {
|
|
720
|
+
if (!known) { this.emit('mail-log', 'relays: the live sessions were not known within two minutes of start; relays of ended sessions are retired at their next end'); return; }
|
|
721
|
+
return this.relays.sweepAtStart((address) => live.sessions().some((row) => row.address === address));
|
|
722
|
+
}).catch((error) => this.emit('mail-log', `relays: the start sweep failed: ${error.message}`));
|
|
703
723
|
live.on('changed', () => {
|
|
704
724
|
if (naming) return;
|
|
705
725
|
naming = setTimeout(() => { naming = null; try { this.mailSend.rememberNames(live.sessions()); } catch { /* kept at the next change */ } }, 1000);
|
|
@@ -999,6 +1019,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
999
1019
|
this.jobs?.stopAll();
|
|
1000
1020
|
this.stoppingMailWatch = true;
|
|
1001
1021
|
this.mailWatch?.kill('SIGTERM');
|
|
1022
|
+
void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
|
|
1002
1023
|
this.stoppingServe = true;
|
|
1003
1024
|
clearTimeout(this.serveRestart);
|
|
1004
1025
|
// said, so a stop reads as one in the log and never as a serve that broke
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The Messages API a Claude relay talks to: deterministic, no model (docs/architecture/overview.md, step 4a; the native
|
|
2
|
+
// watch served it before, and its answers are kept byte for byte).
|
|
3
|
+
//
|
|
4
|
+
// A relay (relay.mjs) is a real Claude Code process, because only a live Claude peer's `SendMessage` gets a Claude
|
|
5
|
+
// session's reply back. What it asks its model is always the same: send the queued message. So a relay's
|
|
6
|
+
// `ANTHROPIC_BASE_URL` points here, and this endpoint answers as the model would, from the relay's own queue:
|
|
7
|
+
// - a send turn gets one `SendMessage` tool call carrying the queued `to` and `message`, byte for byte;
|
|
8
|
+
// - the turn after a tool result, or with nothing queued, gets RELAY_STATUS_LINE and ends.
|
|
9
|
+
// No model runs and nothing is billed. The gate hook still checks every tool call against the queue, and the receipt is
|
|
10
|
+
// still Claude's own `SendMessage` result. A relay's API key names its folder under `<mail root>/relays/`. The machine
|
|
11
|
+
// daemon serves it on loopback, on the port recorded in `<mail root>/relay-endpoint.json` (a restarted daemon binds the
|
|
12
|
+
// same port again).
|
|
13
|
+
import { createServer } from 'node:http';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The text every relay turn ends with. Claude quotes a session's last line in its idle notices, so this is where the
|
|
19
|
+
* correction reaches the agent. */
|
|
20
|
+
export const RELAY_STATUS_LINE = "relay, not the session's status";
|
|
21
|
+
|
|
22
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
23
|
+
const newId = () => `m-${randomBytes(12).toString('hex')}`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serve the endpoint for `mailRoot`'s relays: answers `{ url, close() }` once it listens. The port the record names is
|
|
27
|
+
* taken again when it is free, so relays started before a restart keep reaching it.
|
|
28
|
+
*/
|
|
29
|
+
export async function serveRelayEndpoint(mailRoot, { log = () => {} } = {}) {
|
|
30
|
+
const record = join(mailRoot, 'relay-endpoint.json');
|
|
31
|
+
const server = createServer((request, response) => {
|
|
32
|
+
const chunks = [];
|
|
33
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
34
|
+
request.on('error', () => {});
|
|
35
|
+
request.on('end', () => {
|
|
36
|
+
const [status, type, body] = answer(mailRoot, request, Buffer.concat(chunks));
|
|
37
|
+
response.writeHead(status, { 'content-type': type, 'content-length': Buffer.byteLength(body) });
|
|
38
|
+
response.end(body);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
server.on('clientError', (_error, socket) => { try { socket.destroy(); } catch { /* gone */ } });
|
|
42
|
+
const listen = (port) => new Promise((resolve, reject) => {
|
|
43
|
+
const failed = (error) => { server.off('listening', done); reject(error); };
|
|
44
|
+
const done = () => { server.off('error', failed); resolve(); };
|
|
45
|
+
server.once('error', failed); server.once('listening', done);
|
|
46
|
+
server.listen(port, '127.0.0.1');
|
|
47
|
+
});
|
|
48
|
+
const recorded = readJson(record)?.port;
|
|
49
|
+
try { await listen(Number.isInteger(recorded) ? recorded : 0); }
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!Number.isInteger(recorded)) throw error;
|
|
52
|
+
log(`relay endpoint: its recorded port ${recorded} is taken (${error.code ?? error.message}); listening on another, so relays started before this daemon start again`);
|
|
53
|
+
await listen(0);
|
|
54
|
+
}
|
|
55
|
+
const port = server.address().port;
|
|
56
|
+
mkdirSync(mailRoot, { recursive: true });
|
|
57
|
+
const staging = `${record}.tmp`;
|
|
58
|
+
writeFileSync(staging, JSON.stringify({ port }));
|
|
59
|
+
renameSync(staging, record);
|
|
60
|
+
return { url: `http://127.0.0.1:${port}`, port, close: () => new Promise((resolve) => server.close(() => resolve())) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The relay folder an API key names, when it is one. */
|
|
64
|
+
function relayFolder(mailRoot, request) {
|
|
65
|
+
const key = request.headers['x-api-key'];
|
|
66
|
+
if (typeof key !== 'string' || !key || !/^[0-9a-fA-F]+$/.test(key)) return null;
|
|
67
|
+
const folder = join(mailRoot, 'relays', key);
|
|
68
|
+
try { return statSync(folder).isDirectory() ? folder : null; } catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One request's answer: `[status, content type, body]`. */
|
|
72
|
+
function answer(mailRoot, request, raw) {
|
|
73
|
+
const error = (status, message) => [status, 'application/json', JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message } })];
|
|
74
|
+
if (request.method !== 'POST') return error(404, 'not found');
|
|
75
|
+
const folder = relayFolder(mailRoot, request);
|
|
76
|
+
if (!folder) return error(401, 'not a supercode relay');
|
|
77
|
+
const path = String(request.url ?? '').split('?')[0];
|
|
78
|
+
if (path.endsWith('/v1/messages/count_tokens')) return [200, 'application/json', JSON.stringify({ input_tokens: 1 })];
|
|
79
|
+
if (!path.endsWith('/v1/messages')) return error(404, 'not found');
|
|
80
|
+
let body;
|
|
81
|
+
try { body = JSON.parse(raw.toString('utf8')); } catch { return error(400, 'unreadable request'); }
|
|
82
|
+
const queued = readJson(join(folder, 'queue.json'));
|
|
83
|
+
const content = queued && typeof queued.to === 'string' && typeof queued.message === 'string' && !afterToolResult(body)
|
|
84
|
+
? { type: 'tool_use', id: `toolu_${newId()}`, name: 'SendMessage', input: { to: queued.to, message: queued.message } }
|
|
85
|
+
: { type: 'text', text: RELAY_STATUS_LINE };
|
|
86
|
+
const model = body?.model ?? null;
|
|
87
|
+
return body?.stream === true
|
|
88
|
+
? [200, 'text/event-stream', stream(model, content)]
|
|
89
|
+
: [200, 'application/json', JSON.stringify(message(model, content))];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether the conversation's last turn carries a tool result: the send happened (or was denied), and the turn ends. */
|
|
93
|
+
function afterToolResult(body) {
|
|
94
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
95
|
+
const last = messages[messages.length - 1]?.content;
|
|
96
|
+
return Array.isArray(last) && last.some((block) => block?.type === 'tool_result');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const stopReason = (content) => (content.type === 'tool_use' ? 'tool_use' : 'end_turn');
|
|
100
|
+
|
|
101
|
+
function message(model, content) {
|
|
102
|
+
return { id: `msg_${newId()}`, type: 'message', role: 'assistant', model, content: [content], stop_reason: stopReason(content), stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The same message as the server-sent events a streaming request expects. */
|
|
106
|
+
function stream(model, content) {
|
|
107
|
+
const start = { ...message(model, content), content: [], stop_reason: null };
|
|
108
|
+
const [opening, delta] = content.type === 'tool_use'
|
|
109
|
+
? [{ ...content, input: {} }, { type: 'input_json_delta', partial_json: JSON.stringify(content.input) }]
|
|
110
|
+
: [{ type: 'text', text: '' }, { type: 'text_delta', text: content.text }];
|
|
111
|
+
const events = [
|
|
112
|
+
['message_start', { type: 'message_start', message: start }],
|
|
113
|
+
['content_block_start', { type: 'content_block_start', index: 0, content_block: opening }],
|
|
114
|
+
['content_block_delta', { type: 'content_block_delta', index: 0, delta }],
|
|
115
|
+
['content_block_stop', { type: 'content_block_stop', index: 0 }],
|
|
116
|
+
['message_delta', { type: 'message_delta', delta: { stop_reason: stopReason(content), stop_sequence: null }, usage: { output_tokens: 1 } }],
|
|
117
|
+
['message_stop', { type: 'message_stop' }],
|
|
118
|
+
];
|
|
119
|
+
return events.map(([name, data]) => `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`).join('');
|
|
120
|
+
}
|
package/mail/relay.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// doors, run by the relay's Claude (docs/architecture/overview.md, "Pinned native doors"). Its folder is the native
|
|
8
8
|
// sender's own (`<mail>/relays/<blake3(name)[..24]>/`, its record, settings and send log), so the native `message
|
|
9
9
|
// watch`, which retires relays until step 4, reads what the daemon writes.
|
|
10
|
-
import { mkdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs';
|
|
10
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, watch, writeFileSync } from 'node:fs';
|
|
11
11
|
import { connect } from 'node:net';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { blake3Hex } from './envelope.mjs';
|
|
@@ -25,6 +25,9 @@ export const MAX_RELAYED_BYTES = 100_000;
|
|
|
25
25
|
/** How many relays run at once, the most recently used (a relay runtime is some hundreds of MB; 10 held 2.1 GB on one
|
|
26
26
|
* machine): beyond them a relay's runtime is closed (its folder kept: it starts again when next needed). */
|
|
27
27
|
export const RELAYS_RUNNING = 4;
|
|
28
|
+
/** How long a relay speaking for a sender on another machine is kept after its last send (it carries that sender's
|
|
29
|
+
* mail while it is in use; retired after every send, each message from it started a relay). */
|
|
30
|
+
const REMOTE_RELAY_KEPT_MS = 60 * 60_000;
|
|
28
31
|
/** How long a relay whose runtime start lost its answer is looked at for a runtime that start left running. */
|
|
29
32
|
const LOST_START_KEPT_MS = 5 * 60_000;
|
|
30
33
|
|
|
@@ -63,9 +66,10 @@ export class Relays {
|
|
|
63
66
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
64
67
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
65
68
|
*/
|
|
66
|
-
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], log = () => {} }) {
|
|
67
|
-
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, log });
|
|
69
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, log = () => {} }) {
|
|
70
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, log });
|
|
68
71
|
this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
|
|
72
|
+
this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
|
|
69
73
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
70
74
|
this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
|
|
71
75
|
}
|
|
@@ -103,6 +107,79 @@ export class Relays {
|
|
|
103
107
|
}
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The relay that speaks for `represented` (known as `name`), retired: its runtime closed and its folder removed, under
|
|
112
|
+
* its own lock (as the native watch's minute sweep did, by event here: the session's end, or a remote
|
|
113
|
+
* sender's quiet hour). A board's or an operator's relay is kept: nothing ends it. Answers whether it was retired.
|
|
114
|
+
*/
|
|
115
|
+
async retire(represented, name) {
|
|
116
|
+
const parsed = String(represented).split(':');
|
|
117
|
+
if (parsed[2] === 'board' || parsed[2] === 'operator') return false;
|
|
118
|
+
// a folder is named by its sender's name, not its address: it is retired only while it still represents this ended
|
|
119
|
+
// address (another live session of that name, or one renamed since, may hold it now)
|
|
120
|
+
return this.#retireFolder(this.spec(represented, name).directory, represented);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The address a relay folder speaks for, as its inbound hook names it (its settings), or null. */
|
|
124
|
+
#representedOf(directory) {
|
|
125
|
+
try { return /relay-inbound '([^']+)'/.exec(readFileSync(join(directory, 'settings.json'), 'utf8'))?.[1] ?? null; } catch { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #retireFolder(directory, expected = null) {
|
|
129
|
+
try { statSync(directory); } catch { return false; }
|
|
130
|
+
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
131
|
+
return this.#locked(directory, async () => {
|
|
132
|
+
// read again under its lock: a send that ran meanwhile writes its own sender's settings
|
|
133
|
+
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
134
|
+
const record = readJson(join(directory, 'relay.json'));
|
|
135
|
+
if (record?.runtime_id) {
|
|
136
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, 15_000); }
|
|
137
|
+
catch (error) {
|
|
138
|
+
// a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
|
|
139
|
+
if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
this.running.delete(directory); this.lost.delete(directory);
|
|
143
|
+
clearTimeout(this.remoteKept.get(directory)); this.remoteKept.delete(directory);
|
|
144
|
+
try { rmSync(directory, { recursive: true, force: true }); } catch (error) { this.log(`relay ${directory.split(/[\\/]/).pop()}: its folder was not removed: ${error.message}`); return false; }
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A relay speaking for a sender on another machine is retired REMOTE_RELAY_KEPT_MS after its last send. */
|
|
150
|
+
#keepRemote(directory, quietSinceMs = Date.now()) {
|
|
151
|
+
clearTimeout(this.remoteKept.get(directory));
|
|
152
|
+
const timer = setTimeout(() => { this.remoteKept.delete(directory); void this.#retireFolder(directory); }, Math.max(0, quietSinceMs + REMOTE_RELAY_KEPT_MS - Date.now()));
|
|
153
|
+
timer.unref?.();
|
|
154
|
+
this.remoteKept.set(directory, timer);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* At a daemon's start, once its live sessions are known: the relays whose sessions ended while no daemon ran are
|
|
159
|
+
* retired, and a remote sender's relay is kept for the rest of its hour (one read of the relays folder, at start only).
|
|
160
|
+
* `running(address)` says whether a session here runs.
|
|
161
|
+
*/
|
|
162
|
+
async sweepAtStart(running) {
|
|
163
|
+
let folders = [];
|
|
164
|
+
try { folders = readdirSync(join(this.mailRoot, 'relays')); } catch { return; }
|
|
165
|
+
let retired = 0;
|
|
166
|
+
for (const folder of folders) {
|
|
167
|
+
const directory = join(this.mailRoot, 'relays', folder);
|
|
168
|
+
const represented = this.#representedOf(directory);
|
|
169
|
+
if (!represented) continue;
|
|
170
|
+
const [, machine, harness] = represented.split(':');
|
|
171
|
+
if (harness === 'board' || harness === 'operator') continue;
|
|
172
|
+
if (machine !== this.machine) {
|
|
173
|
+
let last = 0; try { last = statSync(join(directory, 'receipt.json')).mtimeMs; } catch { /* never sent */ }
|
|
174
|
+
if (Date.now() - last < REMOTE_RELAY_KEPT_MS) { this.#keepRemote(directory, last); continue; }
|
|
175
|
+
} else if (harness !== 'claude-code' || running(represented)) continue;
|
|
176
|
+
// only a Claude session is provably ended when the map does not hold it (its registry lists every live Claude
|
|
177
|
+
// process); a sender of another harness the map may not see (Grok, Gemini, a Codex session idle a day) is kept
|
|
178
|
+
if (await this.#retireFolder(directory, represented)) retired++;
|
|
179
|
+
}
|
|
180
|
+
if (retired) this.log(`relays: ${retired} of sessions that ended while no daemon ran retired`);
|
|
181
|
+
}
|
|
182
|
+
|
|
106
183
|
/** `work` run under the relay folder's lock: one send, or one close, at a time per relay, in arrival order. */
|
|
107
184
|
async #locked(directory, work) {
|
|
108
185
|
const previous = this.sending.get(directory) ?? Promise.resolve();
|
|
@@ -129,13 +206,8 @@ export class Relays {
|
|
|
129
206
|
};
|
|
130
207
|
}
|
|
131
208
|
|
|
132
|
-
/** The relay endpoint's base URL
|
|
133
|
-
|
|
134
|
-
async endpoint() {
|
|
135
|
-
const port = readJson(join(this.mailRoot, 'relay-endpoint.json'))?.port;
|
|
136
|
-
if (!Number.isInteger(port)) return null;
|
|
137
|
-
return (await answers(port)) ? `http://127.0.0.1:${port}` : null;
|
|
138
|
-
}
|
|
209
|
+
/** The relay endpoint's base URL: this daemon's own (relay-endpoint.mjs), or null when it is not served. */
|
|
210
|
+
async endpoint() { return this.endpointOf(); }
|
|
139
211
|
|
|
140
212
|
/** Settings installing a relay's hooks: its gate on every tool, its receipt on SendMessage and on a turn's end, and its
|
|
141
213
|
* inbound filing (claude_relay.rs `relay_settings`). */
|
|
@@ -168,7 +240,7 @@ export class Relays {
|
|
|
168
240
|
async #ensure(spec, step = () => {}) {
|
|
169
241
|
const endpoint = await this.endpoint();
|
|
170
242
|
step('relay endpoint');
|
|
171
|
-
if (!endpoint) throw new Error('the relay endpoint is not
|
|
243
|
+
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');
|
|
172
244
|
// written on every send: Claude Code reloads a changed settings file, so a running relay takes the hooks this
|
|
173
245
|
// supercode installs
|
|
174
246
|
writeFileSync(spec.settings, JSON.stringify(this.#settings(spec), null, 2));
|
|
@@ -252,7 +324,10 @@ export class Relays {
|
|
|
252
324
|
mkdirSync(spec.directory, { recursive: true });
|
|
253
325
|
const asked = performance.now();
|
|
254
326
|
try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
|
|
255
|
-
finally {
|
|
327
|
+
finally {
|
|
328
|
+
if (String(sender).split(':')[1] !== this.machine) this.#keepRemote(spec.directory);
|
|
329
|
+
this.#cap();
|
|
330
|
+
}
|
|
256
331
|
}
|
|
257
332
|
|
|
258
333
|
async #send(spec, receiver, message, messageId, asked) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.97",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|