@volter/supercode-teams 0.3.95 → 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-records.mjs +8 -13
- package/machine.mjs +45 -3
- 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-records.mjs
CHANGED
|
@@ -21,8 +21,10 @@ const MIN_READ_MS = 2000;
|
|
|
21
21
|
export const BEAT_MS = 15_000;
|
|
22
22
|
export const STALE_MS = 45_000;
|
|
23
23
|
const backoff = (previous) => Math.min(300_000, Math.max(2000, (previous || 1000) * 2));
|
|
24
|
-
/** How often a machine whose daemon is too old to serve records is asked again
|
|
25
|
-
|
|
24
|
+
/** How often a machine whose daemon is too old to serve records is asked again: its update is what answers it, and the
|
|
25
|
+
* server says nothing when its link comes back within the grace (machine-events.mjs records a change of linked only),
|
|
26
|
+
* so it is asked each minute and said once. */
|
|
27
|
+
const TOO_OLD_RETRY_MS = 60_000;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* The producer: `readers[section](detail)` reads one section (async), `detail` naming what changed when the event knows
|
|
@@ -211,15 +213,6 @@ export class RecordHolder {
|
|
|
211
213
|
if (forgot) this.save();
|
|
212
214
|
for (const [machine, held] of this.held) if (!linked.has(machine) && held.state !== 'unlinked') this.let(machine, 'offline: not linked to the team\'s server');
|
|
213
215
|
for (const machine of linked) { const held = this.held.get(machine); if (!held || held.state === 'unlinked' || ((held.state === 'closed' || held.state === 'too-old') && !held.retry)) this.subscribe(machine); }
|
|
214
|
-
// a machine too old to serve records whose link changed since (it linked again: an update restarts its daemon) is
|
|
215
|
-
// asked again now, not at its ten-minute re-ask
|
|
216
|
-
const changedAt = new Map((items ?? []).filter((item) => item.linked).map((item) => [normal(item.name), item.changed_at ?? null]));
|
|
217
|
-
for (const [machine, held] of this.held) {
|
|
218
|
-
const at = changedAt.get(machine);
|
|
219
|
-
if (at == null) continue;
|
|
220
|
-
if (held.state === 'too-old' && held.linkChangedAt != null && at !== held.linkChangedAt) { held.linkChangedAt = at; this.subscribe(machine); continue; }
|
|
221
|
-
held.linkChangedAt = at;
|
|
222
|
-
}
|
|
223
216
|
}
|
|
224
217
|
let(machine, why) {
|
|
225
218
|
const held = this.held.get(machine); if (!held) return;
|
|
@@ -245,11 +238,13 @@ export class RecordHolder {
|
|
|
245
238
|
// opened again after a backoff, 2 s doubling to 5 min: an obligation of this machine, never a sweep. A daemon too
|
|
246
239
|
// old to serve records is asked again every TOO_OLD_RETRY_MS: only its update answers it (asked every 8-16 s, it
|
|
247
240
|
// filled the log)
|
|
241
|
+
const saidBefore = state === 'too-old' && held.saidTooOld;
|
|
248
242
|
held.backoff = state === 'too-old' ? TOO_OLD_RETRY_MS : backoff(held.backoff);
|
|
243
|
+
held.saidTooOld = state === 'too-old';
|
|
249
244
|
if (this.closed) return;
|
|
250
245
|
held.retry = setTimeout(() => { held.retry = null; if ((held.state === 'closed' || held.state === 'too-old') && !this.closed) this.subscribe(machine); }, held.backoff);
|
|
251
246
|
held.retry.unref?.();
|
|
252
|
-
this.log(`machine records from ${machine}: ${held.why}; read again in ${Math.round(held.backoff / 1000)} s`);
|
|
247
|
+
if (!saidBefore) this.log(`machine records from ${machine}: ${held.why}; read again in ${Math.round(held.backoff / 1000)} s${state === 'too-old' ? ' (and each minute after, said again only once it changes)' : ''}`);
|
|
253
248
|
};
|
|
254
249
|
// the peer answers within STALE_MS (any frame, its beat included) or its record is stale, with why, and reopened
|
|
255
250
|
const heard = () => { clearTimeout(held.quiet); held.quiet = setTimeout(() => closed(`no frame from ${machine} within ${STALE_MS / 1000} s (its record stream went quiet)`), STALE_MS); held.quiet.unref?.(); };
|
|
@@ -316,7 +311,7 @@ export class RecordHolder {
|
|
|
316
311
|
}
|
|
317
312
|
held.record = record;
|
|
318
313
|
} else return null;
|
|
319
|
-
Object.assign(held, { state: 'open', since: held.state === 'open' ? held.since : Date.now(), why: null, received_at: Date.now(), backoff: 0 });
|
|
314
|
+
Object.assign(held, { state: 'open', since: held.state === 'open' ? held.since : Date.now(), why: null, received_at: Date.now(), backoff: 0, saidTooOld: false });
|
|
320
315
|
this.save();
|
|
321
316
|
return null;
|
|
322
317
|
}
|
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';
|
|
@@ -622,6 +623,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
622
623
|
if (change.kind === 'removed' && change.key?.harness === 'codex') this.live.codexIndex({ removed: change.key.session_id });
|
|
623
624
|
else if (change.descriptor) this.live.codexIndex({ descriptor: change.descriptor });
|
|
624
625
|
}
|
|
626
|
+
this.emit('codex-index', { changes: params.changes ?? [] });
|
|
625
627
|
}
|
|
626
628
|
});
|
|
627
629
|
// The Codex sessions' own index (Codex rollouts only): the live map knows them from its first answer, never waiting on
|
|
@@ -634,6 +636,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
634
636
|
this.live.codexRowsReplaced();
|
|
635
637
|
for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) this.live.codexIndex({ descriptor });
|
|
636
638
|
this.live.codexIndexed();
|
|
639
|
+
// the records take their Codex rows from this index too (their own whole index leaves Codex out: one watch and
|
|
640
|
+
// one read of the rollouts)
|
|
641
|
+
this.emit('codex-index', { initial: answer?.initial ?? [] });
|
|
637
642
|
}, (error) => {
|
|
638
643
|
codexIndexBackoff = Math.min(300_000, Math.max(2000, (codexIndexBackoff || 1000) * 2));
|
|
639
644
|
this.emit('records-log', `live sessions: the Codex sessions' index could not be subscribed (${error.message}); tried again in ${Math.round(codexIndexBackoff / 1000)} s`);
|
|
@@ -649,6 +654,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
649
654
|
// ---- this machine's mail (mail/; docs/architecture/overview.md, "The mail operations") ---------------------------
|
|
650
655
|
#startMail() {
|
|
651
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; });
|
|
652
660
|
this.mailStore = new MailStore({ mailRoot, machine: machineName(this.env), log: (line) => this.emit('records-log', line) }).start();
|
|
653
661
|
// the session index's rows by address (its descriptors, as the records' index subscription hands them over)
|
|
654
662
|
this.sessionDescriptors = new Map();
|
|
@@ -674,6 +682,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
674
682
|
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
675
683
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
676
684
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
685
|
+
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
677
686
|
log: (line) => this.emit('mail-log', line) });
|
|
678
687
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
679
688
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
@@ -696,6 +705,21 @@ export class TeamsMachine extends MachineCore {
|
|
|
696
705
|
// the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
|
|
697
706
|
// runs under another, kept from the map's changes (at most once a second)
|
|
698
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}`));
|
|
699
723
|
live.on('changed', () => {
|
|
700
724
|
if (naming) return;
|
|
701
725
|
naming = setTimeout(() => { naming = null; try { this.mailSend.rememberNames(live.sessions()); } catch { /* kept at the next change */ } }, 1000);
|
|
@@ -794,7 +818,22 @@ export class TeamsMachine extends MachineCore {
|
|
|
794
818
|
// is back, and holds its own retry (2 s doubling to 5 min) until it is (MR4); nothing reads its fields as current.
|
|
795
819
|
const backoff = (previous) => Math.min(300_000, Math.max(2000, (previous || 1000) * 2));
|
|
796
820
|
const both = () => { producer.touch('sessions'); producer.touch('panes'); };
|
|
797
|
-
|
|
821
|
+
// every harness but Codex, whose rows come from the live map's Codex-only index ('codex-index' below)
|
|
822
|
+
const HARNESSES = ['claude-code', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
|
|
823
|
+
const isCodex = (key) => key.startsWith('codex:') || key.includes(':codex:');
|
|
824
|
+
const applyRow = (change) => {
|
|
825
|
+
if (change.kind === 'removed' && change.key) { this.sessionDescriptors?.delete(`sc:${machineName(this.env)}:${change.key.harness}:${change.key.session_id}`); locators.delete(`${change.key.harness}:${change.key.session_id}`); }
|
|
826
|
+
else if (change.descriptor?.locator) { const { harness, session_id } = change.descriptor.locator; this.sessionDescriptors?.set(`sc:${machineName(this.env)}:${harness}:${session_id}`, change.descriptor); locators.set(`${harness}:${session_id}`, change.descriptor.locator); }
|
|
827
|
+
};
|
|
828
|
+
this.on('codex-index', ({ initial = null, changes = [] }) => {
|
|
829
|
+
if (initial) {
|
|
830
|
+
for (const key of [...locators.keys()]) if (isCodex(key)) locators.delete(key);
|
|
831
|
+
for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (isCodex(key)) this.sessionDescriptors.delete(key);
|
|
832
|
+
for (const descriptor of initial) applyRow({ descriptor });
|
|
833
|
+
}
|
|
834
|
+
for (const change of changes) applyRow(change);
|
|
835
|
+
both();
|
|
836
|
+
});
|
|
798
837
|
const locators = new Map(); // `${harness}:${session_id}` → the index's locator for it
|
|
799
838
|
const noteIndexParts = (failed, error) => {
|
|
800
839
|
// the index's own unread parts (a store, a folder, a registry it could not read): their sessions are unknown
|
|
@@ -817,8 +856,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
817
856
|
let indexBackoff = 0;
|
|
818
857
|
const subscribe = () => this.#serveRequest('harness.v1.sessions.index.subscribe', { harnesses: HARNESSES, limit: 0 }, 60_000)
|
|
819
858
|
.then((answer) => {
|
|
820
|
-
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0;
|
|
821
|
-
|
|
859
|
+
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0;
|
|
860
|
+
// its own rows replaced; the Codex rows (the live map's index's) kept
|
|
861
|
+
for (const key of [...locators.keys()]) if (!isCodex(key)) locators.delete(key);
|
|
862
|
+
for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (!isCodex(key)) this.sessionDescriptors.delete(key);
|
|
822
863
|
for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) { locators.set(`${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor.locator); this.sessionDescriptors?.set(`sc:${machineName(this.env)}:${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor); }
|
|
823
864
|
producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
|
|
824
865
|
}, (error) => {
|
|
@@ -978,6 +1019,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
978
1019
|
this.jobs?.stopAll();
|
|
979
1020
|
this.stoppingMailWatch = true;
|
|
980
1021
|
this.mailWatch?.kill('SIGTERM');
|
|
1022
|
+
void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
|
|
981
1023
|
this.stoppingServe = true;
|
|
982
1024
|
clearTimeout(this.serveRestart);
|
|
983
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",
|