@volter/supercode-teams 0.3.96 → 0.3.98
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 +141 -5
- package/machine.mjs +49 -28
- package/mail/relay-endpoint.mjs +120 -0
- package/mail/relay.mjs +96 -13
- package/mail/send.mjs +18 -5
- 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,36 @@ function linuxStart(pid) {
|
|
|
53
53
|
} catch { return null; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** The parent of the live process `pid` (Linux's /proc; on macOS one `ps` for every pid asked in the same tick, as a
|
|
57
|
+
* start's read of thirty markers asks thirty), or null when it cannot be read. */
|
|
58
|
+
const parentsAsked = new Map(); // pid → [resolve], asked of the next `ps`
|
|
59
|
+
function parentOf(pid) {
|
|
60
|
+
if (process.platform === 'linux') {
|
|
61
|
+
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); }
|
|
62
|
+
}
|
|
63
|
+
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const first = parentsAsked.size === 0;
|
|
66
|
+
if (!parentsAsked.has(pid)) parentsAsked.set(pid, []);
|
|
67
|
+
parentsAsked.get(pid).push(resolve);
|
|
68
|
+
if (!first) return;
|
|
69
|
+
setImmediate(() => {
|
|
70
|
+
const asked = new Map(parentsAsked); parentsAsked.clear();
|
|
71
|
+
execFile('ps', ['-o', 'pid=,ppid=', '-p', [...asked.keys()].join(',')], { timeout: 10_000 }, (_error, stdout) => {
|
|
72
|
+
const parents = new Map(String(stdout ?? '').trim().split('\n').map((line) => line.trim().split(/\s+/).map(Number)).filter(([child, parent]) => child && parent).map(([child, parent]) => [child, parent]));
|
|
73
|
+
for (const [child, waiters] of asked) for (const done of waiters) done(parents.get(child) ?? null);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The command name of the live process `pid` (Linux's /proc comm, or one `ps` on macOS), or null. */
|
|
80
|
+
function commandOf(pid) {
|
|
81
|
+
if (process.platform === 'linux') { try { return Promise.resolve(readFileSync(`/proc/${pid}/comm`, 'utf8').trim()); } catch { return Promise.resolve(null); } }
|
|
82
|
+
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
83
|
+
return new Promise((resolve) => execFile('ps', ['-o', 'comm=', '-p', String(pid)], { timeout: 10_000 }, (error, stdout) => resolve(error ? null : String(stdout).trim() || null)));
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
/** `path` with its symlinks resolved, or null when it cannot be (gone, or not a string). */
|
|
57
87
|
function realPath(path) { try { return typeof path === 'string' ? realpathSync(path) : null; } catch { return null; } }
|
|
58
88
|
|
|
@@ -110,6 +140,11 @@ export class LiveSessions extends EventEmitter {
|
|
|
110
140
|
this.codexIndexRead = false; this.codexKnown = false; this.codexWaiters = [];
|
|
111
141
|
this.codexRunning = new Map(); // session id → turn (from activity)
|
|
112
142
|
this.codexPaneOf = new Map(); // session id → pane
|
|
143
|
+
// Grok's own registry of its open sessions (`<grok home>/active_sessions.json`: session_id, pid, cwd, opened_at): a
|
|
144
|
+
// session is live while its process is; the daemon pane it runs in, when it does, from the panes the records read
|
|
145
|
+
this.grokHome = env.GROK_HOME || join(homedir(), '.grok');
|
|
146
|
+
this.grok = new Map(); // session id → { pid, ppid, cwd, openedAt, pane }
|
|
147
|
+
this.grokPaneRoots = [];
|
|
113
148
|
this.projectHooks = new Map(); // directory → whether its .codex/hooks.json carries the hook
|
|
114
149
|
this.userHook = false;
|
|
115
150
|
this.watchers = [];
|
|
@@ -133,6 +168,9 @@ export class LiveSessions extends EventEmitter {
|
|
|
133
168
|
this.sweep = setInterval(() => this.#sweepDead(), 30_000);
|
|
134
169
|
this.sweep.unref?.();
|
|
135
170
|
this.#follow('live_runtimes', this.receiptsDir, (name) => this.#readReceipt(name), () => this.#readAll(this.receiptsDir, (name) => this.#readReceipt(name)));
|
|
171
|
+
// Grok's registry, followed where Grok is installed (its home is never made here; a symlinked home is followed at its
|
|
172
|
+
// target, where its file events are)
|
|
173
|
+
this.#followGrok();
|
|
136
174
|
this.#follow('tools_markers', this.markersDir, (name) => this.#readMarker(name), () => this.#readAll(this.markersDir, (name) => this.#readMarker(name)));
|
|
137
175
|
this.#follow('codex_hooks', dirname(this.codexHooks), (name) => { if (!name || name === 'hooks.json') this.#readUserHook(); }, () => this.#readUserHook());
|
|
138
176
|
return this;
|
|
@@ -179,7 +217,13 @@ export class LiveSessions extends EventEmitter {
|
|
|
179
217
|
if (stopped) return;
|
|
180
218
|
try {
|
|
181
219
|
mkdirSync(folder, { recursive: true });
|
|
182
|
-
|
|
220
|
+
// a read that throws is said, never the daemon's end (a malformed entry of a source this map follows)
|
|
221
|
+
// said once until a read passes: a source that keeps throwing does not fill the log
|
|
222
|
+
const guarded = (read) => {
|
|
223
|
+
try { read(); this.failing?.delete(source); }
|
|
224
|
+
catch (error) { this.failing ??= new Set(); if (!this.failing.has(source)) { this.failing.add(source); this.log(`live sessions: ${source} could not be read (${error.message}); said again once it reads`); } }
|
|
225
|
+
};
|
|
226
|
+
watcher = watch(folder, (_event, name) => guarded(() => (name ? onFile(String(name)) : readAll())));
|
|
183
227
|
watcher.on('error', (error) => {
|
|
184
228
|
try { watcher.close(); } catch { /* closed */ }
|
|
185
229
|
this.watchers = this.watchers.filter((w) => w !== watcher);
|
|
@@ -187,7 +231,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
187
231
|
});
|
|
188
232
|
this.watchers.push(watcher);
|
|
189
233
|
this.unread.delete(source);
|
|
190
|
-
readAll
|
|
234
|
+
guarded(readAll);
|
|
191
235
|
delay = 0;
|
|
192
236
|
} catch (error) { lost(error); }
|
|
193
237
|
};
|
|
@@ -210,13 +254,19 @@ export class LiveSessions extends EventEmitter {
|
|
|
210
254
|
}
|
|
211
255
|
|
|
212
256
|
/** 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) {
|
|
257
|
+
#dropClaude(pid) {
|
|
258
|
+
this.claude.delete(pid);
|
|
259
|
+
// its messaging tools' marker goes with it when it has ended (step 4a: by its end, never a minute's sweep); an entry
|
|
260
|
+
// dropped for another reason (its home no longer declared, a parked process) keeps its marker for its return
|
|
261
|
+
if (this.markers.has(pid) && !alive(pid)) { this.markers.delete(pid); try { unlinkSync(join(this.markersDir, String(pid))); } catch { /* gone */ } }
|
|
262
|
+
}
|
|
214
263
|
|
|
215
264
|
/** Every held process asked whether it is alive (signal 0): a dead one's entry goes. */
|
|
216
265
|
#sweepDead() {
|
|
217
266
|
let changed = false;
|
|
218
267
|
for (const pid of [...this.claude.keys()]) if (!alive(pid)) { this.#dropClaude(pid); changed = true; }
|
|
219
268
|
for (const [id, runtime] of [...this.runtimes]) if (!alive(runtime.pid)) { this.runtimes.delete(id); changed = true; }
|
|
269
|
+
for (const [id, entry] of [...this.grok]) if (!alive(entry.pid)) { this.grok.delete(id); changed = true; }
|
|
220
270
|
for (const [pid, server] of [...this.markers]) if (!alive(server)) this.markers.delete(pid);
|
|
221
271
|
if (changed) this.#changed();
|
|
222
272
|
}
|
|
@@ -358,6 +408,74 @@ export class LiveSessions extends EventEmitter {
|
|
|
358
408
|
return (await answersAs(entry.probe)) !== false;
|
|
359
409
|
}
|
|
360
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Grok's registry read whole: each entry whose process is alive is a live Grok session; one whose process started after
|
|
413
|
+
* its `opened_at` is its pid used again (checked once per entry, as a receipt's is). Grok may write the file only at a
|
|
414
|
+
* session's open, so each entry is judged by its process, never by the file's age.
|
|
415
|
+
*/
|
|
416
|
+
#readGrok(home) {
|
|
417
|
+
let entries = null;
|
|
418
|
+
try { entries = JSON.parse(readFileSync(join(home, 'active_sessions.json'), 'utf8')); } catch { return; } // torn or absent: its next event reads it again
|
|
419
|
+
if (!Array.isArray(entries)) return;
|
|
420
|
+
const next = new Map();
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
if (typeof entry?.session_id !== 'string' || !entry.session_id || !Number.isInteger(entry.pid) || !alive(entry.pid)) continue;
|
|
423
|
+
const known = this.grok.get(entry.session_id);
|
|
424
|
+
next.set(entry.session_id, known && known.pid === entry.pid ? known : { pid: entry.pid, ppid: null, cwd: typeof entry.cwd === 'string' ? entry.cwd : null, openedAt: Date.parse(entry.opened_at), pane: null, checked: false });
|
|
425
|
+
}
|
|
426
|
+
// an entry Grok dropped while its process lives (Grok may drop one before the process ends) is kept while that
|
|
427
|
+
// process lives and no entry names its pid now (a session switched in the same process names it anew)
|
|
428
|
+
const named = new Set([...next.values()].map((entry) => entry.pid));
|
|
429
|
+
for (const [id, entry] of this.grok) if (!next.has(id) && !named.has(entry.pid) && alive(entry.pid)) next.set(id, entry);
|
|
430
|
+
this.grok = next;
|
|
431
|
+
for (const [id, entry] of next) {
|
|
432
|
+
if (entry.checked) continue;
|
|
433
|
+
entry.checked = true;
|
|
434
|
+
void Promise.all([startedMs(entry.pid), parentOf(entry.pid), commandOf(entry.pid)]).then(([started, parent, command]) => {
|
|
435
|
+
if (this.grok.get(id) !== entry) return;
|
|
436
|
+
// a pid used again: its process started after the session opened, and it is no Grok (a wall clock that stepped
|
|
437
|
+
// makes a live Grok's start read late; its command still names it)
|
|
438
|
+
if (Number.isFinite(started) && Number.isFinite(entry.openedAt) && started > entry.openedAt + 2000 && command != null && !/grok/i.test(command)) {
|
|
439
|
+
this.grok.delete(id);
|
|
440
|
+
this.log(`live sessions: Grok's registry names pid ${entry.pid} for ${id}, but that process started after the session opened: the pid was used again; it is not taken`);
|
|
441
|
+
} else { entry.ppid = parent; this.#grokPane(entry); }
|
|
442
|
+
this.#changed();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
this.#changed();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Grok's registry followed once its home exists: at start, or when Grok is installed later (its home's parent watched,
|
|
449
|
+
* for that one name, until it appears). */
|
|
450
|
+
#followGrok() {
|
|
451
|
+
const grokHome = realPath(this.grokHome);
|
|
452
|
+
if (grokHome) { this.#follow('grok_sessions', grokHome, (name) => { if (name === 'active_sessions.json') this.#readGrok(grokHome); }, () => this.#readGrok(grokHome)); return; }
|
|
453
|
+
try {
|
|
454
|
+
const parent = dirname(this.grokHome), name = this.grokHome.slice(parent.length + 1);
|
|
455
|
+
const watcher = watch(parent, (_event, changed) => {
|
|
456
|
+
if (String(changed ?? '') !== name || !realPath(this.grokHome)) return;
|
|
457
|
+
try { watcher.close(); } catch { /* closed */ }
|
|
458
|
+
this.watchers = this.watchers.filter((w) => w !== watcher);
|
|
459
|
+
this.#followGrok();
|
|
460
|
+
});
|
|
461
|
+
watcher.on('error', () => { try { watcher.close(); } catch { /* closed */ } });
|
|
462
|
+
this.watchers.push(watcher);
|
|
463
|
+
} catch { /* its parent is not watchable: Grok installed later is followed at the next start */ }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The daemon pane a Grok session runs in: the pane whose process it is, or whose process started it. */
|
|
467
|
+
#grokPane(entry) {
|
|
468
|
+
entry.pane = this.grokPaneRoots.find((root) => root.pid === entry.pid || (entry.ppid != null && root.pid === entry.ppid))?.pane ?? null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The daemon's live panes and their root processes (`[{ pane, pid }]`), as the records read them: Grok's panes. */
|
|
472
|
+
grokPanes(roots) {
|
|
473
|
+
this.grokPaneRoots = (roots ?? []).filter((root) => root?.pane && Number.isInteger(root.pid));
|
|
474
|
+
const before = JSON.stringify([...this.grok].map(([id, entry]) => [id, entry.pane]));
|
|
475
|
+
for (const entry of this.grok.values()) this.#grokPane(entry);
|
|
476
|
+
if (JSON.stringify([...this.grok].map(([id, entry]) => [id, entry.pane])) !== before) this.#changed();
|
|
477
|
+
}
|
|
478
|
+
|
|
361
479
|
/** One messaging-tools marker: the session process named by its file, the server pid in it. */
|
|
362
480
|
#readMarker(name) {
|
|
363
481
|
if (!name) return;
|
|
@@ -365,9 +483,21 @@ export class LiveSessions extends EventEmitter {
|
|
|
365
483
|
if (!Number.isInteger(pid)) return;
|
|
366
484
|
let server = null;
|
|
367
485
|
try { server = Number(readFileSync(join(this.markersDir, name), 'utf8').trim()); } catch { /* removed */ }
|
|
368
|
-
|
|
486
|
+
// a marker whose server has ended names no tools: removed (read at start, and as each is written or changed)
|
|
487
|
+
if (Number.isInteger(server) && server > 0 && !alive(server)) { try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ } server = null; }
|
|
488
|
+
if (!(Number.isInteger(server) && server > 0)) { if (this.markers.delete(pid)) this.#changed(); return; }
|
|
489
|
+
this.markers.set(pid, server);
|
|
490
|
+
// its server is the session's own child (a harness starts its MCP servers so), checked once per marker: a server pid
|
|
491
|
+
// used again by another process names no tools (mail_route.rs `has_message_tools` read it so)
|
|
492
|
+
void parentOf(server).then((parent) => {
|
|
493
|
+
if (parent == null || parent === pid || this.markers.get(pid) !== server) return;
|
|
494
|
+
this.markers.delete(pid);
|
|
495
|
+
try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ }
|
|
496
|
+
this.#changed();
|
|
497
|
+
});
|
|
369
498
|
}
|
|
370
499
|
|
|
500
|
+
|
|
371
501
|
#readUserHook() {
|
|
372
502
|
let text = '';
|
|
373
503
|
try { text = readFileSync(this.codexHooks, 'utf8'); } catch { /* none */ }
|
|
@@ -512,6 +642,12 @@ export class LiveSessions extends EventEmitter {
|
|
|
512
642
|
const hooked = this.userHook || this.#projectHooked(row?.cwd ?? null);
|
|
513
643
|
out.push({ address, name: `codex-${sessionId.slice(0, 8)}@${this.machine}`, status, door: hooked ? 'hook' : 'stored', pid: null, cwd: row?.cwd ?? null, pane: this.codexPaneOf.get(sessionId) ?? null, tools: false });
|
|
514
644
|
}
|
|
645
|
+
// Grok: no hook and no relay reaches it; its mail is typed into its daemon pane, or filed in its mailbox (stored)
|
|
646
|
+
for (const [sessionId, entry] of this.grok) {
|
|
647
|
+
const address = this.#address('grok', sessionId);
|
|
648
|
+
if (seen.has(address) || !alive(entry.pid)) continue; seen.add(address);
|
|
649
|
+
out.push({ address, name: `grok-${sessionId.slice(0, 8)}@${this.machine}`, status: 'idle', door: entry.pane ? 'pane' : 'stored', pid: entry.pid, cwd: entry.cwd, pane: entry.pane, tools: false });
|
|
650
|
+
}
|
|
515
651
|
return out;
|
|
516
652
|
}
|
|
517
653
|
|
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';
|
|
@@ -105,6 +106,8 @@ const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
|
|
|
105
106
|
const INITIAL_INPUT_POLL_MS = 250;
|
|
106
107
|
/** The pause between the two composer readings a user's turn waits on. */
|
|
107
108
|
const COMPOSER_RECHECK_MS = 400;
|
|
109
|
+
/** The harnesses whose composer a capture is read for a draft (sdk/terminal `composerHoldsDraft`). */
|
|
110
|
+
const DRAFT_KINDS = new Set(['claude-code', 'codex', 'gemini', 'grok']);
|
|
108
111
|
// Claude Code waits out a usage limit and queues every message typed meanwhile until the limit's reset ("new messages
|
|
109
112
|
// wait for your usage limit to reset"; esc or /rate-limit-options ends the wait). A limit raised early, or a weekly
|
|
110
113
|
// one past 24 hours, never ends that wait, so a session sat with every turn it was sent queued behind it. Read from the
|
|
@@ -546,6 +549,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
546
549
|
// ---- lifecycle ------------------------------------------------------------
|
|
547
550
|
|
|
548
551
|
async beforeListen() {
|
|
552
|
+
this.bornAt ??= Date.now();
|
|
549
553
|
mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
|
|
550
554
|
this.#startServe();
|
|
551
555
|
if (this.#pendingNotices().length) this.#watchEnds();
|
|
@@ -622,7 +626,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
622
626
|
if (change.kind === 'removed' && change.key?.harness === 'codex') this.live.codexIndex({ removed: change.key.session_id });
|
|
623
627
|
else if (change.descriptor) this.live.codexIndex({ descriptor: change.descriptor });
|
|
624
628
|
}
|
|
625
|
-
this.emit('codex-index', { changes: params.changes ?? [] });
|
|
626
629
|
}
|
|
627
630
|
});
|
|
628
631
|
// The Codex sessions' own index (Codex rollouts only): the live map knows them from its first answer, never waiting on
|
|
@@ -635,9 +638,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
635
638
|
this.live.codexRowsReplaced();
|
|
636
639
|
for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) this.live.codexIndex({ descriptor });
|
|
637
640
|
this.live.codexIndexed();
|
|
638
|
-
// the records take their Codex rows from this index too (their own whole index leaves Codex out: one watch and
|
|
639
|
-
// one read of the rollouts)
|
|
640
|
-
this.emit('codex-index', { initial: answer?.initial ?? [] });
|
|
641
641
|
}, (error) => {
|
|
642
642
|
codexIndexBackoff = Math.min(300_000, Math.max(2000, (codexIndexBackoff || 1000) * 2));
|
|
643
643
|
this.emit('records-log', `live sessions: the Codex sessions' index could not be subscribed (${error.message}); tried again in ${Math.round(codexIndexBackoff / 1000)} s`);
|
|
@@ -653,6 +653,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
653
653
|
// ---- this machine's mail (mail/; docs/architecture/overview.md, "The mail operations") ---------------------------
|
|
654
654
|
#startMail() {
|
|
655
655
|
const mailRoot = join(supercodeHome(this.env), 'mail');
|
|
656
|
+
// the Messages API a Claude relay talks to (step 4a: the daemon's, in process; the native watch served it)
|
|
657
|
+
this.relayEndpoint = serveRelayEndpoint(mailRoot, { log: (line) => this.emit('mail-log', line) })
|
|
658
|
+
.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
659
|
this.mailStore = new MailStore({ mailRoot, machine: machineName(this.env), log: (line) => this.emit('records-log', line) }).start();
|
|
657
660
|
// the session index's rows by address (its descriptors, as the records' index subscription hands them over)
|
|
658
661
|
this.sessionDescriptors = new Map();
|
|
@@ -678,6 +681,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
678
681
|
this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
|
|
679
682
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
680
683
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
684
|
+
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
681
685
|
log: (line) => this.emit('mail-log', line) });
|
|
682
686
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
683
687
|
// 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 +704,22 @@ export class TeamsMachine extends MachineCore {
|
|
|
700
704
|
// the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
|
|
701
705
|
// runs under another, kept from the map's changes (at most once a second)
|
|
702
706
|
let naming = null;
|
|
707
|
+
// a session that has ended has its relay retired (step 4a: by its end, never a minute's sweep); the relays of
|
|
708
|
+
// sessions that ended while no daemon ran are retired once, at start, when the live sessions are known
|
|
709
|
+
let wasLive = new Map(live.sessions().map((row) => [row.address, row.name]));
|
|
710
|
+
live.on('changed', () => {
|
|
711
|
+
// while the Codex sessions are not known (a start, a serve restart) a Codex session missing has not ended: the last
|
|
712
|
+
// known view stands until they are known again
|
|
713
|
+
if (!live.codexKnown) return;
|
|
714
|
+
const now = new Map(live.sessions().map((row) => [row.address, row.name]));
|
|
715
|
+
// a Codex session that leaves the map has not provably ended (idle over a day, it is no longer followed): kept
|
|
716
|
+
for (const [address, name] of wasLive) if (!now.has(address) && address.split(':')[2] !== 'codex') 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);
|
|
@@ -755,6 +775,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
755
775
|
const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
|
|
756
776
|
const panes = rows.filter((row) => row.session?.id).map(({ session: s }) => ({ pane: s.contextKey, dead: Boolean(s.dead), pid: s.rootPid ?? null, label: s.label ?? null, title: titles[`pane:${s.contextKey}`] ?? s.label ?? null, cwd: s.cwd ?? null }));
|
|
757
777
|
const live = panes.filter((pane) => !pane.dead && pane.pid);
|
|
778
|
+
// the pane each Grok session runs in (Grok's registry names its process; its pane is the one rooted at it)
|
|
779
|
+
this.live?.grokPanes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid })));
|
|
758
780
|
// the harness session under each live pane, as the board reads it: unread is said, never no session
|
|
759
781
|
let activities = [], sessions_unread = null;
|
|
760
782
|
if (live.length) {
|
|
@@ -798,22 +820,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
798
820
|
// is back, and holds its own retry (2 s doubling to 5 min) until it is (MR4); nothing reads its fields as current.
|
|
799
821
|
const backoff = (previous) => Math.min(300_000, Math.max(2000, (previous || 1000) * 2));
|
|
800
822
|
const both = () => { producer.touch('sessions'); producer.touch('panes'); };
|
|
801
|
-
// every harness
|
|
802
|
-
|
|
803
|
-
const
|
|
804
|
-
const applyRow = (change) => {
|
|
805
|
-
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}`); }
|
|
806
|
-
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); }
|
|
807
|
-
};
|
|
808
|
-
this.on('codex-index', ({ initial = null, changes = [] }) => {
|
|
809
|
-
if (initial) {
|
|
810
|
-
for (const key of [...locators.keys()]) if (isCodex(key)) locators.delete(key);
|
|
811
|
-
for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (isCodex(key)) this.sessionDescriptors.delete(key);
|
|
812
|
-
for (const descriptor of initial) applyRow({ descriptor });
|
|
813
|
-
}
|
|
814
|
-
for (const change of changes) applyRow(change);
|
|
815
|
-
both();
|
|
816
|
-
});
|
|
823
|
+
// every harness, Codex among them: the live map reads Codex alone for its speed, and the records keep the whole
|
|
824
|
+
// index (a session's parent may be of another harness, which only the whole index links)
|
|
825
|
+
const HARNESSES = ['claude-code', 'codex', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
|
|
817
826
|
const locators = new Map(); // `${harness}:${session_id}` → the index's locator for it
|
|
818
827
|
const noteIndexParts = (failed, error) => {
|
|
819
828
|
// the index's own unread parts (a store, a folder, a registry it could not read): their sessions are unknown
|
|
@@ -836,10 +845,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
836
845
|
let indexBackoff = 0;
|
|
837
846
|
const subscribe = () => this.#serveRequest('harness.v1.sessions.index.subscribe', { harnesses: HARNESSES, limit: 0 }, 60_000)
|
|
838
847
|
.then((answer) => {
|
|
839
|
-
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0;
|
|
840
|
-
|
|
841
|
-
for (const key of [...locators.keys()]) if (!isCodex(key)) locators.delete(key);
|
|
842
|
-
for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (!isCodex(key)) this.sessionDescriptors.delete(key);
|
|
848
|
+
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0; locators.clear();
|
|
849
|
+
this.sessionDescriptors?.clear();
|
|
843
850
|
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); }
|
|
844
851
|
producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
|
|
845
852
|
}, (error) => {
|
|
@@ -999,6 +1006,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
999
1006
|
this.jobs?.stopAll();
|
|
1000
1007
|
this.stoppingMailWatch = true;
|
|
1001
1008
|
this.mailWatch?.kill('SIGTERM');
|
|
1009
|
+
void this.relayEndpoint?.then((endpoint) => endpoint?.close()).catch(() => {});
|
|
1002
1010
|
this.stoppingServe = true;
|
|
1003
1011
|
clearTimeout(this.serveRestart);
|
|
1004
1012
|
// said, so a stop reads as one in the log and never as a serve that broke
|
|
@@ -1026,8 +1034,11 @@ export class TeamsMachine extends MachineCore {
|
|
|
1026
1034
|
const serveLines = lineSplitter();
|
|
1027
1035
|
// a serve already replaced never answers into the streams that its successor serves (its late answers would reach a
|
|
1028
1036
|
// reopened stream that uses the same ids)
|
|
1037
|
+
let answered = false;
|
|
1029
1038
|
child.stdout.on('data', (chunk) => {
|
|
1030
1039
|
if (this.serve !== child) return;
|
|
1040
|
+
// when serve first answers, and so how much of a start's wait is serve's own start (said once per serve)
|
|
1041
|
+
if (!answered) { answered = true; this.emit('records-log', `harness serve: its first line ${((Date.now() - started) / 1000).toFixed(1)} s after its spawn (${((Date.now() - (this.bornAt ?? started)) / 1000).toFixed(1)} s after the daemon's start)`); }
|
|
1031
1042
|
for (const line of serveLines(chunk)) if (line) this.#routeServeLine(line);
|
|
1032
1043
|
});
|
|
1033
1044
|
child.stderr.setEncoding('utf8');
|
|
@@ -1119,7 +1130,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1119
1130
|
const entry = this.connections.get(conn);
|
|
1120
1131
|
if (conn === INTERNAL_CONNECTION) {
|
|
1121
1132
|
const pending = this.internalRequests?.get(original);
|
|
1122
|
-
if (!pending)
|
|
1133
|
+
if (!pending) {
|
|
1134
|
+
// an answer that came after its request gave up: an index it opened is registered in serve with nobody reading
|
|
1135
|
+
// it (one more at each retry), so it is let go at once
|
|
1136
|
+
const late = message.result?.subscription;
|
|
1137
|
+
if (typeof late === 'string' && late.startsWith('index-sub-')) this.#toServe(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, `late-${late}`), method: 'harness.v1.sessions.index.unsubscribe', params: { subscription: late } })}\n`);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1123
1140
|
this.internalRequests.delete(original);
|
|
1124
1141
|
// the daemon's own subscriptions (its machine record's session index) are routed back to it, as an operator's are
|
|
1125
1142
|
if (message.result && typeof message.result === 'object') {
|
|
@@ -2207,15 +2224,19 @@ export class TeamsMachine extends MachineCore {
|
|
|
2207
2224
|
if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
|
|
2208
2225
|
return { refused: { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` } };
|
|
2209
2226
|
}
|
|
2210
|
-
|
|
2227
|
+
// the composer read for a draft: the pane's own program, or (a pane whose program shows another name, as Grok's
|
|
2228
|
+
// versioned binary does) the harness the text is for; a program whose composer this cannot read is not read
|
|
2229
|
+
const draftKind = DRAFT_KINDS.has(kind) ? kind : (addressed && DRAFT_KINDS.has(addressed) ? addressed : null);
|
|
2230
|
+
if (draftKind) {
|
|
2211
2231
|
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
2212
2232
|
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
2213
2233
|
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
2214
|
-
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2234
|
+
if (draftKind === 'claude-code' && !this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2215
2235
|
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
2216
2236
|
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2217
2237
|
}
|
|
2218
|
-
|
|
2238
|
+
// a person's draft is never typed over, nor a screen with no composer (a menu, a dialog) typed into
|
|
2239
|
+
const draftOf = async () => composerHoldsDraft(draftKind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
2219
2240
|
const first = await draftOf();
|
|
2220
2241
|
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2221
2242
|
const second = await draftOf();
|
|
@@ -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,11 @@ 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;
|
|
31
|
+
/** How long a Codex sender's relay is kept unused at a start (a Codex session idle over a day leaves the map). */
|
|
32
|
+
const CODEX_RELAY_KEPT_MS = 7 * 24 * 60 * 60_000;
|
|
28
33
|
/** How long a relay whose runtime start lost its answer is looked at for a runtime that start left running. */
|
|
29
34
|
const LOST_START_KEPT_MS = 5 * 60_000;
|
|
30
35
|
|
|
@@ -63,9 +68,10 @@ export class Relays {
|
|
|
63
68
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
64
69
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
65
70
|
*/
|
|
66
|
-
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], log = () => {} }) {
|
|
67
|
-
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, log });
|
|
71
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, log = () => {} }) {
|
|
72
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, log });
|
|
68
73
|
this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
|
|
74
|
+
this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
|
|
69
75
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
70
76
|
this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
|
|
71
77
|
}
|
|
@@ -103,6 +109,84 @@ export class Relays {
|
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The relay that speaks for `represented` (known as `name`), retired: its runtime closed and its folder removed, under
|
|
114
|
+
* its own lock (as the native watch's minute sweep did, by event here: the session's end, or a remote
|
|
115
|
+
* sender's quiet hour). A board's or an operator's relay is kept: nothing ends it. Answers whether it was retired.
|
|
116
|
+
*/
|
|
117
|
+
async retire(represented, name) {
|
|
118
|
+
const parsed = String(represented).split(':');
|
|
119
|
+
if (parsed[2] === 'board' || parsed[2] === 'operator') return false;
|
|
120
|
+
// a folder is named by its sender's name, not its address: it is retired only while it still represents this ended
|
|
121
|
+
// address (another live session of that name, or one renamed since, may hold it now)
|
|
122
|
+
return this.#retireFolder(this.spec(represented, name).directory, represented);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The address a relay folder speaks for, as its inbound hook names it (its settings), or null. */
|
|
126
|
+
#representedOf(directory) {
|
|
127
|
+
try { return /relay-inbound '([^']+)'/.exec(readFileSync(join(directory, 'settings.json'), 'utf8'))?.[1] ?? null; } catch { return null; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async #retireFolder(directory, expected = null) {
|
|
131
|
+
try { statSync(directory); } catch { return false; }
|
|
132
|
+
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
133
|
+
return this.#locked(directory, async () => {
|
|
134
|
+
// read again under its lock: a send that ran meanwhile writes its own sender's settings
|
|
135
|
+
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
136
|
+
const record = readJson(join(directory, 'relay.json'));
|
|
137
|
+
if (record?.runtime_id) {
|
|
138
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, 15_000); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
// a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
|
|
141
|
+
if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
this.running.delete(directory); this.lost.delete(directory);
|
|
145
|
+
clearTimeout(this.remoteKept.get(directory)); this.remoteKept.delete(directory);
|
|
146
|
+
try { rmSync(directory, { recursive: true, force: true }); } catch (error) { this.log(`relay ${directory.split(/[\\/]/).pop()}: its folder was not removed: ${error.message}`); return false; }
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A relay speaking for a sender on another machine is retired REMOTE_RELAY_KEPT_MS after its last send. */
|
|
152
|
+
#keepRemote(directory, quietSinceMs = Date.now()) {
|
|
153
|
+
clearTimeout(this.remoteKept.get(directory));
|
|
154
|
+
const timer = setTimeout(() => { this.remoteKept.delete(directory); void this.#retireFolder(directory); }, Math.max(0, quietSinceMs + REMOTE_RELAY_KEPT_MS - Date.now()));
|
|
155
|
+
timer.unref?.();
|
|
156
|
+
this.remoteKept.set(directory, timer);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* At a daemon's start, once its live sessions are known: the relays whose sessions ended while no daemon ran are
|
|
161
|
+
* retired, and a remote sender's relay is kept for the rest of its hour (one read of the relays folder, at start only).
|
|
162
|
+
* `running(address)` says whether a session here runs.
|
|
163
|
+
*/
|
|
164
|
+
async sweepAtStart(running) {
|
|
165
|
+
let folders = [];
|
|
166
|
+
try { folders = readdirSync(join(this.mailRoot, 'relays')); } catch { return; }
|
|
167
|
+
let retired = 0;
|
|
168
|
+
for (const folder of folders) {
|
|
169
|
+
const directory = join(this.mailRoot, 'relays', folder);
|
|
170
|
+
const represented = this.#representedOf(directory);
|
|
171
|
+
if (!represented) continue;
|
|
172
|
+
const [, machine, harness] = represented.split(':');
|
|
173
|
+
if (harness === 'board' || harness === 'operator') continue;
|
|
174
|
+
if (machine !== this.machine) {
|
|
175
|
+
let last = 0; try { last = statSync(join(directory, 'receipt.json')).mtimeMs; } catch { /* never sent */ }
|
|
176
|
+
if (Date.now() - last < REMOTE_RELAY_KEPT_MS) { this.#keepRemote(directory, last); continue; }
|
|
177
|
+
} else if (running(represented)) continue;
|
|
178
|
+
else if (harness === 'codex') {
|
|
179
|
+
// a Codex session the map does not hold may be idle, not ended: its relay is kept until unused for a week
|
|
180
|
+
let last = 0; for (const file of ['receipt.json', 'relay.json']) { try { last = Math.max(last, statSync(join(directory, file)).mtimeMs); } catch { /* none */ } }
|
|
181
|
+
if (Date.now() - last < CODEX_RELAY_KEPT_MS) continue;
|
|
182
|
+
} else if (harness !== 'claude-code') continue;
|
|
183
|
+
// only a Claude session is provably ended when the map does not hold it (its registry lists every live Claude
|
|
184
|
+
// process); a sender of another harness the map may not see (Grok, Gemini, a Codex session idle a day) is kept
|
|
185
|
+
if (await this.#retireFolder(directory, represented)) retired++;
|
|
186
|
+
}
|
|
187
|
+
if (retired) this.log(`relays: ${retired} of sessions that ended while no daemon ran retired`);
|
|
188
|
+
}
|
|
189
|
+
|
|
106
190
|
/** `work` run under the relay folder's lock: one send, or one close, at a time per relay, in arrival order. */
|
|
107
191
|
async #locked(directory, work) {
|
|
108
192
|
const previous = this.sending.get(directory) ?? Promise.resolve();
|
|
@@ -129,13 +213,8 @@ export class Relays {
|
|
|
129
213
|
};
|
|
130
214
|
}
|
|
131
215
|
|
|
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
|
-
}
|
|
216
|
+
/** The relay endpoint's base URL: this daemon's own (relay-endpoint.mjs), or null when it is not served. */
|
|
217
|
+
async endpoint() { return this.endpointOf(); }
|
|
139
218
|
|
|
140
219
|
/** Settings installing a relay's hooks: its gate on every tool, its receipt on SendMessage and on a turn's end, and its
|
|
141
220
|
* inbound filing (claude_relay.rs `relay_settings`). */
|
|
@@ -168,7 +247,7 @@ export class Relays {
|
|
|
168
247
|
async #ensure(spec, step = () => {}) {
|
|
169
248
|
const endpoint = await this.endpoint();
|
|
170
249
|
step('relay endpoint');
|
|
171
|
-
if (!endpoint) throw new Error('the relay endpoint is not
|
|
250
|
+
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
251
|
// written on every send: Claude Code reloads a changed settings file, so a running relay takes the hooks this
|
|
173
252
|
// supercode installs
|
|
174
253
|
writeFileSync(spec.settings, JSON.stringify(this.#settings(spec), null, 2));
|
|
@@ -249,14 +328,18 @@ export class Relays {
|
|
|
249
328
|
*/
|
|
250
329
|
async send(sender, senderName, receiver, message, messageId) {
|
|
251
330
|
const spec = this.spec(sender, senderName);
|
|
252
|
-
mkdirSync(spec.directory, { recursive: true });
|
|
253
331
|
const asked = performance.now();
|
|
254
332
|
try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
|
|
255
|
-
finally {
|
|
333
|
+
finally {
|
|
334
|
+
if (String(sender).split(':')[1] !== this.machine) this.#keepRemote(spec.directory);
|
|
335
|
+
this.#cap();
|
|
336
|
+
}
|
|
256
337
|
}
|
|
257
338
|
|
|
258
339
|
async #send(spec, receiver, message, messageId, asked) {
|
|
259
340
|
try {
|
|
341
|
+
// made under its lock: a retire that held the lock meanwhile may have removed it
|
|
342
|
+
mkdirSync(spec.directory, { recursive: true });
|
|
260
343
|
const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
|
|
261
344
|
const steps = [], began = asked;
|
|
262
345
|
let mark = began;
|
package/mail/send.mjs
CHANGED
|
@@ -205,6 +205,8 @@ export class MailSend {
|
|
|
205
205
|
if (session.door === 'runtime') return { kind: 'runtime', session };
|
|
206
206
|
if (session.door === 'native') return { kind: 'native', session };
|
|
207
207
|
if (session.door === 'hook') return { kind: 'hook', session, pane: session.pane ?? null, idle: session.status === 'idle' };
|
|
208
|
+
// a session with no hook and no relay (Grok) in a daemon pane: its mail is typed there, into an empty composer
|
|
209
|
+
if (session.door === 'pane' && session.pane) return { kind: 'pane', session, pane: session.pane };
|
|
208
210
|
return { kind: 'stored', session };
|
|
209
211
|
}
|
|
210
212
|
|
|
@@ -264,7 +266,10 @@ export class MailSend {
|
|
|
264
266
|
queued: [0, 'it is idle and --queue leaves it so; the message waits in its mailbox'],
|
|
265
267
|
operator: [0, 'filed in its mailbox'],
|
|
266
268
|
already: [0, 'it already had this message; nothing was sent again'],
|
|
267
|
-
|
|
269
|
+
typed: [0, 'typed into its pane as its next turn'],
|
|
270
|
+
pane_held: [0, 'held for its pane: typed once its composer is free'],
|
|
271
|
+
pane_waits: [EXIT_STORED, 'Stored (not failed): its pane\'s composer was not empty, so the message waits in its mailbox; it sees it with supercode message inbox. Don\'t resend'],
|
|
272
|
+
stored: [EXIT_STORED, `Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox${parseAddress(address).harness === 'codex' ? ' (a Codex session gets one with: supercode message setup codex)' : ''}. Don't resend, and don't wait for a reply`],
|
|
268
273
|
}[delivered];
|
|
269
274
|
this.#recordSend(caller.address, address, messageId, sentIn);
|
|
270
275
|
const harness = parseAddress(address).harness;
|
|
@@ -322,10 +327,18 @@ export class MailSend {
|
|
|
322
327
|
this.store.deliver(to, sent);
|
|
323
328
|
if (wake) this.store.requestWake(to, sent.id);
|
|
324
329
|
delivered = 'hooked';
|
|
330
|
+
} else if (door.kind === 'pane') {
|
|
331
|
+
// typed as its next turn only into an empty composer (machine.mjs #paneTurn); otherwise it waits in its mailbox
|
|
332
|
+
let typed = null;
|
|
333
|
+
if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id]); } catch { /* waits */ } }
|
|
334
|
+
if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
|
|
335
|
+
// held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
|
|
336
|
+
else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
|
|
337
|
+
else { this.store.deliver(to, sent); delivered = wake ? 'pane_waits' : 'queued'; }
|
|
325
338
|
} else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
|
|
326
339
|
else { this.store.deliver(to, sent); delivered = 'operator'; }
|
|
327
340
|
// a hand-over to the recipient's own door is its delivery, recorded as such
|
|
328
|
-
if (['steered', 'started', 'native_busy', 'native_idle'].includes(delivered)) {
|
|
341
|
+
if (['steered', 'started', 'native_busy', 'native_idle', 'typed'].includes(delivered)) {
|
|
329
342
|
try { this.store.claimFor(to, sent.id, { pid: process.pid, via: 'door', caller: null, recipient: true, at_ms: Date.now() }); } catch { /* best kept */ }
|
|
330
343
|
}
|
|
331
344
|
// nor is an idle notice routed to a claimed address (D200)
|
|
@@ -409,7 +422,7 @@ export class MailSend {
|
|
|
409
422
|
if (error instanceof Refused) throw new Error(`${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
410
423
|
throw error;
|
|
411
424
|
}
|
|
412
|
-
return { steered: 'steered into its running turn', started: 'started a turn', native_busy: 'read at its next tool call', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door' }[how];
|
|
425
|
+
return { steered: 'steered into its running turn', started: 'started a turn', native_busy: 'read at its next tool call', native_idle: 'starts its next turn', hooked: 'in its mailbox, its hook shows it', queued: 'waits in its mailbox', operator: 'filed', already: 'it already had it', stored: 'stored; it has no delivery door', typed: 'typed into its pane', pane_held: 'held for its pane until its composer is free', pane_waits: 'waits in its mailbox (its composer was not empty)' }[how];
|
|
413
426
|
}
|
|
414
427
|
|
|
415
428
|
// ---- a filing --------------------------------------------------------------------------------------------------
|
|
@@ -507,9 +520,9 @@ export class MailSend {
|
|
|
507
520
|
return refused('delivery_failed', error.message);
|
|
508
521
|
}
|
|
509
522
|
return {
|
|
510
|
-
delivered_to_bus: door.kind !== 'stored', message_id: sent.id, reply_to: sender,
|
|
523
|
+
delivered_to_bus: door.kind !== 'stored' && how !== 'pane_waits', message_id: sent.id, reply_to: sender,
|
|
511
524
|
target: { harness, session_id: sessionId, name: door.kind === 'native' ? door.session.name.split('@')[0] : null },
|
|
512
|
-
delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_busy: 'next_tool_call', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already' }[how] },
|
|
525
|
+
delivery: { door: door.kind, how: { steered: 'steered', started: 'started', native_busy: 'next_tool_call', native_idle: 'started', hooked: 'hook', queued: 'queued', stored: 'stored', operator: 'filed', already: 'already', typed: 'typed', pane_held: 'held', pane_waits: 'stored' }[how] },
|
|
513
526
|
};
|
|
514
527
|
}
|
|
515
528
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.98",
|
|
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",
|