@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 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) { this.claude.delete(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
- if (Number.isInteger(server) && server > 0) this.markers.set(pid, server); else this.markers.delete(pid);
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 */ }
@@ -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 (its update is what answers it). */
25
- const TOO_OLD_RETRY_MS = 600_000;
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
- const HARNESSES = ['claude-code', 'codex', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
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; locators.clear();
821
- this.sessionDescriptors?.clear();
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, when it answers (claude_relay.rs `relay_endpoint`; the native watch serves it until
133
- * step 4, its port in `<mail>/relay-endpoint.json`). */
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 answering; the machine daemon\'s `supercode message watch` serves it (see mail/machine-daemon.log)');
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 { this.#cap(); }
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.95",
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",