@volter/supercode-teams 0.3.97 → 0.3.99
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 +114 -4
- package/machine.mjs +59 -31
- package/mail/relay.mjs +102 -19
- package/mail/send.mjs +36 -7
- package/mail/store.mjs +6 -0
- package/package.json +1 -1
package/live-sessions.mjs
CHANGED
|
@@ -53,13 +53,34 @@ function linuxStart(pid) {
|
|
|
53
53
|
} catch { return null; }
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** The parent of the live process `pid` (Linux's /proc
|
|
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`
|
|
57
59
|
function parentOf(pid) {
|
|
58
60
|
if (process.platform === 'linux') {
|
|
59
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); }
|
|
60
62
|
}
|
|
61
63
|
if (process.platform !== 'darwin') return Promise.resolve(null);
|
|
62
|
-
return new Promise((resolve) =>
|
|
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)));
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
/** `path` with its symlinks resolved, or null when it cannot be (gone, or not a string). */
|
|
@@ -119,6 +140,11 @@ export class LiveSessions extends EventEmitter {
|
|
|
119
140
|
this.codexIndexRead = false; this.codexKnown = false; this.codexWaiters = [];
|
|
120
141
|
this.codexRunning = new Map(); // session id → turn (from activity)
|
|
121
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 = [];
|
|
122
148
|
this.projectHooks = new Map(); // directory → whether its .codex/hooks.json carries the hook
|
|
123
149
|
this.userHook = false;
|
|
124
150
|
this.watchers = [];
|
|
@@ -142,6 +168,9 @@ export class LiveSessions extends EventEmitter {
|
|
|
142
168
|
this.sweep = setInterval(() => this.#sweepDead(), 30_000);
|
|
143
169
|
this.sweep.unref?.();
|
|
144
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();
|
|
145
174
|
this.#follow('tools_markers', this.markersDir, (name) => this.#readMarker(name), () => this.#readAll(this.markersDir, (name) => this.#readMarker(name)));
|
|
146
175
|
this.#follow('codex_hooks', dirname(this.codexHooks), (name) => { if (!name || name === 'hooks.json') this.#readUserHook(); }, () => this.#readUserHook());
|
|
147
176
|
return this;
|
|
@@ -188,7 +217,13 @@ export class LiveSessions extends EventEmitter {
|
|
|
188
217
|
if (stopped) return;
|
|
189
218
|
try {
|
|
190
219
|
mkdirSync(folder, { recursive: true });
|
|
191
|
-
|
|
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())));
|
|
192
227
|
watcher.on('error', (error) => {
|
|
193
228
|
try { watcher.close(); } catch { /* closed */ }
|
|
194
229
|
this.watchers = this.watchers.filter((w) => w !== watcher);
|
|
@@ -196,7 +231,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
196
231
|
});
|
|
197
232
|
this.watchers.push(watcher);
|
|
198
233
|
this.unread.delete(source);
|
|
199
|
-
readAll
|
|
234
|
+
guarded(readAll);
|
|
200
235
|
delay = 0;
|
|
201
236
|
} catch (error) { lost(error); }
|
|
202
237
|
};
|
|
@@ -231,6 +266,7 @@ export class LiveSessions extends EventEmitter {
|
|
|
231
266
|
let changed = false;
|
|
232
267
|
for (const pid of [...this.claude.keys()]) if (!alive(pid)) { this.#dropClaude(pid); changed = true; }
|
|
233
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; }
|
|
234
270
|
for (const [pid, server] of [...this.markers]) if (!alive(server)) this.markers.delete(pid);
|
|
235
271
|
if (changed) this.#changed();
|
|
236
272
|
}
|
|
@@ -372,6 +408,74 @@ export class LiveSessions extends EventEmitter {
|
|
|
372
408
|
return (await answersAs(entry.probe)) !== false;
|
|
373
409
|
}
|
|
374
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
|
+
|
|
375
479
|
/** One messaging-tools marker: the session process named by its file, the server pid in it. */
|
|
376
480
|
#readMarker(name) {
|
|
377
481
|
if (!name) return;
|
|
@@ -538,6 +642,12 @@ export class LiveSessions extends EventEmitter {
|
|
|
538
642
|
const hooked = this.userHook || this.#projectHooked(row?.cwd ?? null);
|
|
539
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 });
|
|
540
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
|
+
}
|
|
541
651
|
return out;
|
|
542
652
|
}
|
|
543
653
|
|
package/machine.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import { spawn, spawnSync, execFile } from 'node:child_process';
|
|
|
30
30
|
import { EventEmitter } from 'node:events';
|
|
31
31
|
import { appendFileSync, readFileSync, writeFileSync, writeSync, statSync, readdirSync, mkdirSync, renameSync, rmSync, existsSync, openSync, readSync, closeSync, accessSync, constants } from 'node:fs';
|
|
32
32
|
import { basename, join, resolve, dirname, isAbsolute } from 'node:path';
|
|
33
|
+
import { open as openAsync, readdir as readdirAsync } from 'node:fs/promises';
|
|
33
34
|
import { askBoardOwner, BOARD_OPERATION_VERBS, boardHome } from '@volter/teams/board-owner';
|
|
34
35
|
import { openMachine, normalMachineName } from '@volter/teams/machine-door';
|
|
35
36
|
import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED } from './machine-records.mjs';
|
|
@@ -106,6 +107,8 @@ const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
|
|
|
106
107
|
const INITIAL_INPUT_POLL_MS = 250;
|
|
107
108
|
/** The pause between the two composer readings a user's turn waits on. */
|
|
108
109
|
const COMPOSER_RECHECK_MS = 400;
|
|
110
|
+
/** The harnesses whose composer a capture is read for a draft (sdk/terminal `composerHoldsDraft`). */
|
|
111
|
+
const DRAFT_KINDS = new Set(['claude-code', 'codex', 'gemini', 'grok']);
|
|
109
112
|
// Claude Code waits out a usage limit and queues every message typed meanwhile until the limit's reset ("new messages
|
|
110
113
|
// wait for your usage limit to reset"; esc or /rate-limit-options ends the wait). A limit raised early, or a weekly
|
|
111
114
|
// one past 24 hours, never ends that wait, so a session sat with every turn it was sent queued behind it. Read from the
|
|
@@ -151,6 +154,31 @@ const LAUNCH_SETTLE_MS = 500;
|
|
|
151
154
|
/** How long a send to another machine waits for its mail door's answer (its receiver's door there: a relay's receipt
|
|
152
155
|
* alone may take 90 s). An answer that does not come is unanswered, never refused or failed. */
|
|
153
156
|
const MAIL_SEND_MS = 120_000;
|
|
157
|
+
|
|
158
|
+
/** How much of a transcript's end is read for a message that just landed. */
|
|
159
|
+
const LANDING_TAIL_BYTES = 4 * 1024 * 1024;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether Claude session `sessionId`'s transcript (`<projects>/<folder>/<id>.jsonl`, in any of `projectsDirs`) holds
|
|
163
|
+
* `landed` in its last LANDING_TAIL_BYTES: a relay's send whose receipt was lost is read where it lands. Read apart from
|
|
164
|
+
* the daemon's loop (it runs once per unconfirmed send).
|
|
165
|
+
*/
|
|
166
|
+
async function claudeTranscriptHolds(projectsDirs, sessionId, landed) {
|
|
167
|
+
for (const projects of projectsDirs) {
|
|
168
|
+
let folders = [];
|
|
169
|
+
try { folders = await readdirAsync(projects); } catch { continue; }
|
|
170
|
+
for (const folder of folders) {
|
|
171
|
+
let file;
|
|
172
|
+
try { file = await openAsync(join(projects, folder, `${sessionId}.jsonl`), 'r'); } catch { continue; }
|
|
173
|
+
try {
|
|
174
|
+
const { size } = await file.stat(), length = Math.min(size, LANDING_TAIL_BYTES);
|
|
175
|
+
const { buffer } = await file.read(Buffer.alloc(length), 0, length, size - length);
|
|
176
|
+
if (buffer.toString('utf8').includes(landed)) return true;
|
|
177
|
+
} finally { await file.close(); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
154
182
|
/**
|
|
155
183
|
* The doors whose work waits on a session, with the bound each answers within (the node door's own is 30 s): a send or
|
|
156
184
|
* a hand-over waits on its receiver's door (a relay's receipt, up to 90 s; another machine's door, MAIL_SEND_MS), a
|
|
@@ -547,6 +575,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
547
575
|
// ---- lifecycle ------------------------------------------------------------
|
|
548
576
|
|
|
549
577
|
async beforeListen() {
|
|
578
|
+
this.bornAt ??= Date.now();
|
|
550
579
|
mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
|
|
551
580
|
this.#startServe();
|
|
552
581
|
if (this.#pendingNotices().length) this.#watchEnds();
|
|
@@ -623,7 +652,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
623
652
|
if (change.kind === 'removed' && change.key?.harness === 'codex') this.live.codexIndex({ removed: change.key.session_id });
|
|
624
653
|
else if (change.descriptor) this.live.codexIndex({ descriptor: change.descriptor });
|
|
625
654
|
}
|
|
626
|
-
this.emit('codex-index', { changes: params.changes ?? [] });
|
|
627
655
|
}
|
|
628
656
|
});
|
|
629
657
|
// The Codex sessions' own index (Codex rollouts only): the live map knows them from its first answer, never waiting on
|
|
@@ -636,9 +664,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
636
664
|
this.live.codexRowsReplaced();
|
|
637
665
|
for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) this.live.codexIndex({ descriptor });
|
|
638
666
|
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 ?? [] });
|
|
642
667
|
}, (error) => {
|
|
643
668
|
codexIndexBackoff = Math.min(300_000, Math.max(2000, (codexIndexBackoff || 1000) * 2));
|
|
644
669
|
this.emit('records-log', `live sessions: the Codex sessions' index could not be subscribed (${error.message}); tried again in ${Math.round(codexIndexBackoff / 1000)} s`);
|
|
@@ -683,6 +708,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
683
708
|
runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
|
|
684
709
|
runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
|
|
685
710
|
endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
|
|
711
|
+
arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
|
|
686
712
|
log: (line) => this.emit('mail-log', line) });
|
|
687
713
|
this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
|
|
688
714
|
// a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
|
|
@@ -713,7 +739,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
713
739
|
// known view stands until they are known again
|
|
714
740
|
if (!live.codexKnown) return;
|
|
715
741
|
const now = new Map(live.sessions().map((row) => [row.address, row.name]));
|
|
716
|
-
|
|
742
|
+
// a Codex session that leaves the map has not provably ended (idle over a day, it is no longer followed): kept
|
|
743
|
+
for (const [address, name] of wasLive) if (!now.has(address) && address.split(':')[2] !== 'codex') void this.relays.retire(address, name).catch(() => {});
|
|
717
744
|
wasLive = now;
|
|
718
745
|
});
|
|
719
746
|
void live.whenCodexKnown(120_000).then((known) => {
|
|
@@ -775,6 +802,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
775
802
|
const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
|
|
776
803
|
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 }));
|
|
777
804
|
const live = panes.filter((pane) => !pane.dead && pane.pid);
|
|
805
|
+
// the pane each Grok session runs in (Grok's registry names its process; its pane is the one rooted at it)
|
|
806
|
+
this.live?.grokPanes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid })));
|
|
778
807
|
// the harness session under each live pane, as the board reads it: unread is said, never no session
|
|
779
808
|
let activities = [], sessions_unread = null;
|
|
780
809
|
if (live.length) {
|
|
@@ -818,22 +847,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
818
847
|
// is back, and holds its own retry (2 s doubling to 5 min) until it is (MR4); nothing reads its fields as current.
|
|
819
848
|
const backoff = (previous) => Math.min(300_000, Math.max(2000, (previous || 1000) * 2));
|
|
820
849
|
const both = () => { producer.touch('sessions'); producer.touch('panes'); };
|
|
821
|
-
// every harness
|
|
822
|
-
|
|
823
|
-
const
|
|
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
|
-
});
|
|
850
|
+
// every harness, Codex among them: the live map reads Codex alone for its speed, and the records keep the whole
|
|
851
|
+
// index (a session's parent may be of another harness, which only the whole index links)
|
|
852
|
+
const HARNESSES = ['claude-code', 'codex', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
|
|
837
853
|
const locators = new Map(); // `${harness}:${session_id}` → the index's locator for it
|
|
838
854
|
const noteIndexParts = (failed, error) => {
|
|
839
855
|
// the index's own unread parts (a store, a folder, a registry it could not read): their sessions are unknown
|
|
@@ -856,10 +872,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
856
872
|
let indexBackoff = 0;
|
|
857
873
|
const subscribe = () => this.#serveRequest('harness.v1.sessions.index.subscribe', { harnesses: HARNESSES, limit: 0 }, 60_000)
|
|
858
874
|
.then((answer) => {
|
|
859
|
-
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0;
|
|
860
|
-
|
|
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);
|
|
875
|
+
this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0; locators.clear();
|
|
876
|
+
this.sessionDescriptors?.clear();
|
|
863
877
|
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); }
|
|
864
878
|
producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
|
|
865
879
|
}, (error) => {
|
|
@@ -1047,8 +1061,11 @@ export class TeamsMachine extends MachineCore {
|
|
|
1047
1061
|
const serveLines = lineSplitter();
|
|
1048
1062
|
// a serve already replaced never answers into the streams that its successor serves (its late answers would reach a
|
|
1049
1063
|
// reopened stream that uses the same ids)
|
|
1064
|
+
let answered = false;
|
|
1050
1065
|
child.stdout.on('data', (chunk) => {
|
|
1051
1066
|
if (this.serve !== child) return;
|
|
1067
|
+
// when serve first answers, and so how much of a start's wait is serve's own start (said once per serve)
|
|
1068
|
+
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)`); }
|
|
1052
1069
|
for (const line of serveLines(chunk)) if (line) this.#routeServeLine(line);
|
|
1053
1070
|
});
|
|
1054
1071
|
child.stderr.setEncoding('utf8');
|
|
@@ -1125,7 +1142,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1125
1142
|
this.internalRequests.set(id, { resolve, reject });
|
|
1126
1143
|
if (!this.#toServe(`${JSON.stringify({ jsonrpc: '2.0', id: joinId(INTERNAL_CONNECTION, id), method, params })}\n`)) {
|
|
1127
1144
|
this.internalRequests.delete(id);
|
|
1128
|
-
return reject(new Error('harness serve is not running'));
|
|
1145
|
+
return reject(Object.assign(new Error('harness serve is not running'), { notAsked: true }));
|
|
1129
1146
|
}
|
|
1130
1147
|
setTimeout(() => { if (this.internalRequests.delete(id)) reject(new Error(`${method} timed out`)); }, timeoutMs).unref();
|
|
1131
1148
|
});
|
|
@@ -1140,7 +1157,13 @@ export class TeamsMachine extends MachineCore {
|
|
|
1140
1157
|
const entry = this.connections.get(conn);
|
|
1141
1158
|
if (conn === INTERNAL_CONNECTION) {
|
|
1142
1159
|
const pending = this.internalRequests?.get(original);
|
|
1143
|
-
if (!pending)
|
|
1160
|
+
if (!pending) {
|
|
1161
|
+
// an answer that came after its request gave up: an index it opened is registered in serve with nobody reading
|
|
1162
|
+
// it (one more at each retry), so it is let go at once
|
|
1163
|
+
const late = message.result?.subscription;
|
|
1164
|
+
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`);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1144
1167
|
this.internalRequests.delete(original);
|
|
1145
1168
|
// the daemon's own subscriptions (its machine record's session index) are routed back to it, as an operator's are
|
|
1146
1169
|
if (message.result && typeof message.result === 'object') {
|
|
@@ -1149,7 +1172,8 @@ export class TeamsMachine extends MachineCore {
|
|
|
1149
1172
|
if (typeof value === 'string') { if (!this.routes.has(value)) this.routes.set(value, new Set()); this.routes.get(value).add(INTERNAL_CONNECTION); }
|
|
1150
1173
|
}
|
|
1151
1174
|
}
|
|
1152
|
-
|
|
1175
|
+
// an answer that is an error is marked answered: a call refused, not one whose outcome is unknown
|
|
1176
|
+
if (message.error) pending.reject(Object.assign(new Error(message.error.message ?? 'harness serve refused'), { answered: true, code: message.error.code ?? null }));
|
|
1153
1177
|
else pending.resolve(message.result);
|
|
1154
1178
|
return;
|
|
1155
1179
|
}
|
|
@@ -2228,15 +2252,19 @@ export class TeamsMachine extends MachineCore {
|
|
|
2228
2252
|
if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
|
|
2229
2253
|
return { refused: { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` } };
|
|
2230
2254
|
}
|
|
2231
|
-
|
|
2255
|
+
// the composer read for a draft: the pane's own program, or (a pane whose program shows another name, as Grok's
|
|
2256
|
+
// versioned binary does) the harness the text is for; a program whose composer this cannot read is not read
|
|
2257
|
+
const draftKind = DRAFT_KINDS.has(kind) ? kind : (addressed && DRAFT_KINDS.has(addressed) ? addressed : null);
|
|
2258
|
+
if (draftKind) {
|
|
2232
2259
|
// A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
|
|
2233
2260
|
// still holds refuses the turn at once and the session waits again, for the next turn to try.
|
|
2234
2261
|
const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
|
|
2235
|
-
if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2262
|
+
if (draftKind === 'claude-code' && !this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
|
|
2236
2263
|
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
|
|
2237
2264
|
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2238
2265
|
}
|
|
2239
|
-
|
|
2266
|
+
// a person's draft is never typed over, nor a screen with no composer (a menu, a dialog) typed into
|
|
2267
|
+
const draftOf = async () => composerHoldsDraft(draftKind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
|
|
2240
2268
|
const first = await draftOf();
|
|
2241
2269
|
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
2242
2270
|
const second = await draftOf();
|
package/mail/relay.mjs
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
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
|
-
import { blake3Hex } from './envelope.mjs';
|
|
13
|
+
import { blake3Hex, shortId } from './envelope.mjs';
|
|
14
14
|
|
|
15
15
|
/** Model name a relay's requests carry; the relay endpoint answers them without any model. */
|
|
16
16
|
const RELAY_MODEL = 'haiku';
|
|
@@ -18,6 +18,26 @@ const RELAY_MODEL = 'haiku';
|
|
|
18
18
|
export const RELAY_NAME_PREFIX = 'sc-';
|
|
19
19
|
/** How long a send waits for Claude's own receipt. */
|
|
20
20
|
const SEND_TIMEOUT_MS = 90_000;
|
|
21
|
+
|
|
22
|
+
/** How long an unconfirmed send is given to land in its receiver's transcript before that is read: a SendMessage that
|
|
23
|
+
* passed the gate just before the send ended lands within it. */
|
|
24
|
+
const LANDING_MS = 2000;
|
|
25
|
+
|
|
26
|
+
/** How long a hand-in waits for its relay's last turn to end: a relay's receipt is written mid-turn (at its
|
|
27
|
+
* SendMessage), so the next send through it may find that turn still finishing. Past this the turn is stuck. */
|
|
28
|
+
const TURN_END_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
/** A relay close's bound: over harness serve's own (RUNTIME_CONTROL_DEADLINE, 30 s), past which serve ends the
|
|
31
|
+
* runtime's process group itself, so a relay stuck in a turn is closed through serve, never left running. */
|
|
32
|
+
const CLOSE_MS = 35_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether harness serve's error for a relay's input leaves its outcome unknown: no answer (its bound passed, serve ended
|
|
36
|
+
* mid-call) or serve's own deadline on the runtime ("gave up after", harness_service.rs `within_control_deadline`).
|
|
37
|
+
* Any other answer is a refusal of the input (a turn in progress, an unknown or closed connection), and a call never
|
|
38
|
+
* made (no serve to ask) was not handed in either.
|
|
39
|
+
*/
|
|
40
|
+
const answerLost = (error) => !error.notAsked && (!error.answered || /gave up after \d+s/.test(error.message));
|
|
21
41
|
/** A relay send slower than this says where its time went. */
|
|
22
42
|
const SLOW_SEND_MS = 2000;
|
|
23
43
|
/** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
|
|
@@ -28,6 +48,8 @@ export const RELAYS_RUNNING = 4;
|
|
|
28
48
|
/** How long a relay speaking for a sender on another machine is kept after its last send (it carries that sender's
|
|
29
49
|
* mail while it is in use; retired after every send, each message from it started a relay). */
|
|
30
50
|
const REMOTE_RELAY_KEPT_MS = 60 * 60_000;
|
|
51
|
+
/** How long a Codex sender's relay is kept unused at a start (a Codex session idle over a day leaves the map). */
|
|
52
|
+
const CODEX_RELAY_KEPT_MS = 7 * 24 * 60 * 60_000;
|
|
31
53
|
/** How long a relay whose runtime start lost its answer is looked at for a runtime that start left running. */
|
|
32
54
|
const LOST_START_KEPT_MS = 5 * 60_000;
|
|
33
55
|
|
|
@@ -66,8 +88,8 @@ export class Relays {
|
|
|
66
88
|
* id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
|
|
67
89
|
* `runtimeNamed(receiptId)` one receipt read by its own name.
|
|
68
90
|
*/
|
|
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 });
|
|
91
|
+
constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, log = () => {} }) {
|
|
92
|
+
Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, log });
|
|
71
93
|
this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
|
|
72
94
|
this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
|
|
73
95
|
this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
|
|
@@ -84,7 +106,7 @@ export class Relays {
|
|
|
84
106
|
this.running.delete(directory);
|
|
85
107
|
// closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
|
|
86
108
|
// again, never handing its turn to a runtime that is closing
|
|
87
|
-
void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id },
|
|
109
|
+
void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, CLOSE_MS))
|
|
88
110
|
.catch((error) => {
|
|
89
111
|
// still running, so still counted: the next cap closes it again (unless a send started it anew meanwhile)
|
|
90
112
|
if (!this.running.has(directory)) this.running.set(directory, relay);
|
|
@@ -100,7 +122,7 @@ export class Relays {
|
|
|
100
122
|
for (const ids of this.runtimesIn('claude-code', directory)) {
|
|
101
123
|
if (ids.includes(named)) continue;
|
|
102
124
|
void this.#locked(directory, async () => {
|
|
103
|
-
for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id },
|
|
125
|
+
for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, CLOSE_MS); return; } catch { /* the other id */ } }
|
|
104
126
|
this.log(`relay ${directory.split(/[\\/]/).pop()}: a runtime its lost start left running was not closed`);
|
|
105
127
|
});
|
|
106
128
|
}
|
|
@@ -133,7 +155,7 @@ export class Relays {
|
|
|
133
155
|
if (expected && this.#representedOf(directory) !== expected) return false;
|
|
134
156
|
const record = readJson(join(directory, 'relay.json'));
|
|
135
157
|
if (record?.runtime_id) {
|
|
136
|
-
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id },
|
|
158
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection ?? '', runtime_id: record.runtime_id }, CLOSE_MS); }
|
|
137
159
|
catch (error) {
|
|
138
160
|
// a runtime still live that could not be closed keeps its folder: it is retired again at the next end or start
|
|
139
161
|
if (this.runtimeOf('claude-code', record.runtime_id)) { this.log(`relay ${directory.split(/[\\/]/).pop()} was not retired: ${error.message}`); return false; }
|
|
@@ -172,7 +194,12 @@ export class Relays {
|
|
|
172
194
|
if (machine !== this.machine) {
|
|
173
195
|
let last = 0; try { last = statSync(join(directory, 'receipt.json')).mtimeMs; } catch { /* never sent */ }
|
|
174
196
|
if (Date.now() - last < REMOTE_RELAY_KEPT_MS) { this.#keepRemote(directory, last); continue; }
|
|
175
|
-
} else if (
|
|
197
|
+
} else if (running(represented)) continue;
|
|
198
|
+
else if (harness === 'codex') {
|
|
199
|
+
// a Codex session the map does not hold may be idle, not ended: its relay is kept until unused for a week
|
|
200
|
+
let last = 0; for (const file of ['receipt.json', 'relay.json']) { try { last = Math.max(last, statSync(join(directory, file)).mtimeMs); } catch { /* none */ } }
|
|
201
|
+
if (Date.now() - last < CODEX_RELAY_KEPT_MS) continue;
|
|
202
|
+
} else if (harness !== 'claude-code') continue;
|
|
176
203
|
// only a Claude session is provably ended when the map does not hold it (its registry lists every live Claude
|
|
177
204
|
// process); a sender of another harness the map may not see (Grok, Gemini, a Codex session idle a day) is kept
|
|
178
205
|
if (await this.#retireFolder(directory, represented)) retired++;
|
|
@@ -237,7 +264,7 @@ export class Relays {
|
|
|
237
264
|
|
|
238
265
|
/** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
|
|
239
266
|
* `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
|
|
240
|
-
async #ensure(spec, step = () => {}) {
|
|
267
|
+
async #ensure(spec, step = () => {}, { stale = false } = {}) {
|
|
241
268
|
const endpoint = await this.endpoint();
|
|
242
269
|
step('relay endpoint');
|
|
243
270
|
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');
|
|
@@ -247,15 +274,16 @@ export class Relays {
|
|
|
247
274
|
const record = readJson(spec.record);
|
|
248
275
|
if (record?.runtime_id) {
|
|
249
276
|
const runtime = this.runtimeOf('claude-code', record.runtime_id);
|
|
250
|
-
if (runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
|
|
277
|
+
if (!stale && runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
|
|
251
278
|
step('runtime reused');
|
|
252
279
|
return { runtime, connection: record.connection };
|
|
253
280
|
}
|
|
254
281
|
// why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
|
|
255
|
-
step(`runtime not reused (${!runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
|
|
282
|
+
step(`runtime not reused (${stale ? 'it held a stale turn' : !runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
|
|
256
283
|
// closed through the owner's door even after its receipt went
|
|
257
|
-
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id },
|
|
284
|
+
try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, CLOSE_MS); }
|
|
258
285
|
catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
|
|
286
|
+
step('old runtime closed (runtimes.close)');
|
|
259
287
|
}
|
|
260
288
|
const key = spec.directory.split(/[\\/]/).pop();
|
|
261
289
|
const started = await this.#start(spec, {
|
|
@@ -290,7 +318,7 @@ export class Relays {
|
|
|
290
318
|
if (!runtime) {
|
|
291
319
|
// a runtime no send can use is closed, never left running untracked
|
|
292
320
|
this.running.delete(spec.directory);
|
|
293
|
-
try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId },
|
|
321
|
+
try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId }, CLOSE_MS); } catch { /* said below */ }
|
|
294
322
|
throw new Error('the relay started but registered no live runtime; it was closed');
|
|
295
323
|
}
|
|
296
324
|
return { runtime, connection };
|
|
@@ -305,11 +333,12 @@ export class Relays {
|
|
|
305
333
|
const check = () => {
|
|
306
334
|
const value = readJson(spec.receipt);
|
|
307
335
|
if (value) return finish(readReceipt(value));
|
|
308
|
-
|
|
336
|
+
// not known either way: it may have sent before it ended
|
|
337
|
+
if (pid && !alive(pid)) finish({ delivered: false, unknown: true, detail: 'the Claude relay process exited before confirming the send' });
|
|
309
338
|
};
|
|
310
339
|
try { watcher = watch(spec.directory, () => check()); watcher.on('error', () => {}); } catch { /* checked on the timer */ }
|
|
311
340
|
timer = setInterval(check, 500);
|
|
312
|
-
deadline = setTimeout(() => finish({ delivered: false, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
|
|
341
|
+
deadline = setTimeout(() => finish({ delivered: false, unknown: true, detail: `the Claude relay did not confirm the send within ${SEND_TIMEOUT_MS / 1000} seconds; it may still arrive` }), SEND_TIMEOUT_MS);
|
|
313
342
|
check();
|
|
314
343
|
});
|
|
315
344
|
}
|
|
@@ -321,17 +350,20 @@ export class Relays {
|
|
|
321
350
|
*/
|
|
322
351
|
async send(sender, senderName, receiver, message, messageId) {
|
|
323
352
|
const spec = this.spec(sender, senderName);
|
|
324
|
-
mkdirSync(spec.directory, { recursive: true });
|
|
325
353
|
const asked = performance.now();
|
|
326
|
-
|
|
354
|
+
// the message as its receiver's transcript holds it once delivered (envelope.mjs render, its tag escaped there)
|
|
355
|
+
const landed = `cross-session-message id=\\"${shortId(messageId)}\\" from=\\"${sender}\\"`;
|
|
356
|
+
try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked, landed)); }
|
|
327
357
|
finally {
|
|
328
358
|
if (String(sender).split(':')[1] !== this.machine) this.#keepRemote(spec.directory);
|
|
329
359
|
this.#cap();
|
|
330
360
|
}
|
|
331
361
|
}
|
|
332
362
|
|
|
333
|
-
async #send(spec, receiver, message, messageId, asked) {
|
|
363
|
+
async #send(spec, receiver, message, messageId, asked, landed) {
|
|
334
364
|
try {
|
|
365
|
+
// made under its lock: a retire that held the lock meanwhile may have removed it
|
|
366
|
+
mkdirSync(spec.directory, { recursive: true });
|
|
335
367
|
const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
|
|
336
368
|
const steps = [], began = asked;
|
|
337
369
|
let mark = began;
|
|
@@ -343,8 +375,30 @@ export class Relays {
|
|
|
343
375
|
writeFileSync(spec.queue, JSON.stringify(queued));
|
|
344
376
|
let receipt;
|
|
345
377
|
for (let attempt = 0; ; attempt++) {
|
|
346
|
-
|
|
347
|
-
|
|
378
|
+
// A hand-in whose answer did not come (its bound passed, serve ended mid-call, serve's own deadline on the
|
|
379
|
+
// runtime) may still have reached the relay, which then sends: its receipt is waited on as after any hand-in. A
|
|
380
|
+
// hand-in refused (an answer that is an error) or never asked (no serve) did not reach it, and is said at once,
|
|
381
|
+
// never held for a receipt that cannot come.
|
|
382
|
+
let handed = true;
|
|
383
|
+
const error = await this.#handIn(connection, queued, step);
|
|
384
|
+
if (!error) step('turn handed in (send_input)');
|
|
385
|
+
else if (answerLost(error)) step(`turn handed in, its answer lost (send_input: ${error.message})`);
|
|
386
|
+
else {
|
|
387
|
+
handed = false;
|
|
388
|
+
// still in a turn after TURN_END_MS while this send holds its lock: a turn of a send that already ended (its
|
|
389
|
+
// queue gone, so its gate refuses whatever that turn tries), stuck. The relay is closed through serve and
|
|
390
|
+
// started again, once
|
|
391
|
+
if (attempt === 0 && /already in progress/.test(error.message)) {
|
|
392
|
+
step(`relay still in a turn after ${TURN_END_MS / 1000} s, closed (${error.message})`);
|
|
393
|
+
({ runtime, connection } = await this.#ensure(spec, step, { stale: true }));
|
|
394
|
+
this.#used(spec, connection);
|
|
395
|
+
rmSync(spec.receipt, { force: true });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
receipt = { delivered: false, detail: `the Claude relay did not take the send: ${error.message}` };
|
|
399
|
+
}
|
|
400
|
+
if (!handed) break;
|
|
401
|
+
receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt');
|
|
348
402
|
const pid = this.#pid(spec, runtime);
|
|
349
403
|
// only a relay proven dead is started again; one alive but slow may still deliver
|
|
350
404
|
if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
|
|
@@ -352,6 +406,16 @@ export class Relays {
|
|
|
352
406
|
rmSync(spec.receipt, { force: true });
|
|
353
407
|
}
|
|
354
408
|
rmSync(spec.queue, { force: true });
|
|
409
|
+
// an outcome not confirmed is read where it would have landed: the relay can no longer send it (its queue is
|
|
410
|
+
// gone), so the receiver's transcript says whether it did
|
|
411
|
+
if (!receipt.delivered && receipt.unknown) {
|
|
412
|
+
await new Promise((resolve) => setTimeout(resolve, LANDING_MS));
|
|
413
|
+
let found = false;
|
|
414
|
+
try { found = await this.arrived(receiver.sessionId, landed); } catch { /* unread: unknown */ }
|
|
415
|
+
step(found ? 'found in its receiver\'s transcript' : 'not in its receiver\'s transcript');
|
|
416
|
+
if (found) receipt = { delivered: true };
|
|
417
|
+
else receipt = { ...receipt, landed: false };
|
|
418
|
+
}
|
|
355
419
|
if (receipt.delivered) {
|
|
356
420
|
const sent = readJson(spec.sent) ?? {};
|
|
357
421
|
sent[receiver.sessionId] = messageId;
|
|
@@ -366,6 +430,25 @@ export class Relays {
|
|
|
366
430
|
}
|
|
367
431
|
}
|
|
368
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Hand the send's turn to the relay (`runtimes.send_input`): null once taken, else the error. A relay still in a turn
|
|
435
|
+
* (its last send's: its receipt is written mid-turn, the turn ends a moment later) is asked again every 250 ms for up
|
|
436
|
+
* to TURN_END_MS; the error is then that refusal.
|
|
437
|
+
*/
|
|
438
|
+
async #handIn(connection, queued, step) {
|
|
439
|
+
const asked = performance.now();
|
|
440
|
+
for (;;) {
|
|
441
|
+
try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); }
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (answerLost(error) || !/already in progress/.test(error.message) || performance.now() - asked > TURN_END_MS) return error;
|
|
444
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (performance.now() - asked > 250) step(`waited for its last turn to end`);
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
369
452
|
/** `runtimes.start` for a relay: a start that fails (its answer lost, or late past its bound) may still have left a
|
|
370
453
|
* runtime running, so its folder is looked at by the next caps (#cap). */
|
|
371
454
|
async #start(spec, params, timeoutMs) {
|
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
|
|
|
@@ -252,6 +254,8 @@ export class MailSend {
|
|
|
252
254
|
catch (error) {
|
|
253
255
|
if (error instanceof Refused && error.kind === 'queue') return outcome(EXIT_REFUSED, `Not sent: ${name} is idle, and a Claude session always starts a turn when a message arrives, so --queue cannot hold it. Send without --queue to wake it.`);
|
|
254
256
|
if (error instanceof Refused) return outcome(EXIT_REFUSED, `Not sent: the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}. Write it to a file and send its path instead.`);
|
|
257
|
+
// may have arrived: kept filed, and said unanswered with its id (sent again with the same id, nothing is sent twice)
|
|
258
|
+
if (error.unanswered) { this.#recordSend(caller.address, address, messageId, sentIn); return outcome(EXIT_UNANSWERED, unansweredText(`${name}'s Claude relay`, error.message, messageId)); }
|
|
255
259
|
return outcome(EXIT_FAILED, `Not sent to ${name}: ${error.message}`);
|
|
256
260
|
}
|
|
257
261
|
const [code, what] = {
|
|
@@ -264,7 +268,10 @@ export class MailSend {
|
|
|
264
268
|
queued: [0, 'it is idle and --queue leaves it so; the message waits in its mailbox'],
|
|
265
269
|
operator: [0, 'filed in its mailbox'],
|
|
266
270
|
already: [0, 'it already had this message; nothing was sent again'],
|
|
267
|
-
|
|
271
|
+
typed: [0, 'typed into its pane as its next turn'],
|
|
272
|
+
pane_held: [0, 'held for its pane: typed once its composer is free'],
|
|
273
|
+
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'],
|
|
274
|
+
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
275
|
}[delivered];
|
|
269
276
|
this.#recordSend(caller.address, address, messageId, sentIn);
|
|
270
277
|
const harness = parseAddress(address).harness;
|
|
@@ -316,16 +323,34 @@ export class MailSend {
|
|
|
316
323
|
const filedHere = this.store.findIn(to, handed.id) == null;
|
|
317
324
|
const filed = this.store.deliverRead(to, handed);
|
|
318
325
|
const relayed = await this.relays.send(handed.from, handed.from_name, { sessionId: door.session.address.split(':').slice(3).join(':'), name: door.session.name.split('@')[0], socket: door.session.socket }, text, handed.id);
|
|
319
|
-
|
|
326
|
+
// a send the relay may have made keeps its copy and is unanswered, never taken back and said not sent; only one the
|
|
327
|
+
// relay is known not to have made (refused, never handed in) takes its copy back. One its receiver read from its
|
|
328
|
+
// mailbox meanwhile (its recipient claim) has reached it. Otherwise the relay can no longer make it (its queue is
|
|
329
|
+
// gone, so its gate refuses a late send) and it is not in the receiver's transcript: a copy this delivery filed
|
|
330
|
+
// goes back unread, for the receiver's own mailbox read and the mailbox's wake, which hands it over again
|
|
331
|
+
const received = this.store.claimOf(to, handed.id)?.recipient === true;
|
|
332
|
+
if (!relayed.delivered && relayed.unknown && !received) {
|
|
333
|
+
if (filedHere) { try { this.store.markUnread(to, filed); if (wake) this.store.requestWake(to, handed.id); } catch { /* kept read */ } }
|
|
334
|
+
throw Object.assign(new Error(relayed.detail), { unanswered: true });
|
|
335
|
+
}
|
|
336
|
+
if (!relayed.delivered && !received) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
|
|
320
337
|
delivered = busy ? 'native_busy' : 'native_idle';
|
|
321
338
|
} else if (door.kind === 'hook') {
|
|
322
339
|
this.store.deliver(to, sent);
|
|
323
340
|
if (wake) this.store.requestWake(to, sent.id);
|
|
324
341
|
delivered = 'hooked';
|
|
342
|
+
} else if (door.kind === 'pane') {
|
|
343
|
+
// typed as its next turn only into an empty composer (machine.mjs #paneTurn); otherwise it waits in its mailbox
|
|
344
|
+
let typed = null;
|
|
345
|
+
if (wake) { try { typed = await this.typeTurn(door.pane, parseAddress(to).harness, render(sent), [sent.id]); } catch { /* waits */ } }
|
|
346
|
+
if (typed?.delivered) { this.store.deliverRead(to, sent); delivered = 'typed'; }
|
|
347
|
+
// held by the daemon's queue for the pane, typed once its composer frees: not typed yet, so kept unread
|
|
348
|
+
else if (typed?.queued) { this.store.deliver(to, sent); delivered = 'pane_held'; }
|
|
349
|
+
else { this.store.deliver(to, sent); delivered = wake ? 'pane_waits' : 'queued'; }
|
|
325
350
|
} else if (door.kind === 'stored') { this.store.deliver(to, sent); delivered = 'stored'; }
|
|
326
351
|
else { this.store.deliver(to, sent); delivered = 'operator'; }
|
|
327
352
|
// 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)) {
|
|
353
|
+
if (['steered', 'started', 'native_busy', 'native_idle', 'typed'].includes(delivered)) {
|
|
329
354
|
try { this.store.claimFor(to, sent.id, { pid: process.pid, via: 'door', caller: null, recipient: true, at_ms: Date.now() }); } catch { /* best kept */ }
|
|
330
355
|
}
|
|
331
356
|
// nor is an idle notice routed to a claimed address (D200)
|
|
@@ -355,7 +380,7 @@ export class MailSend {
|
|
|
355
380
|
continue;
|
|
356
381
|
}
|
|
357
382
|
try { reached.push(`${label} (${await this.#deliverWoken(sent, recipient.address, sessions, queue, notify_when_idle)})`); }
|
|
358
|
-
catch (error) { failed.push(`${label} (${error.message})`); }
|
|
383
|
+
catch (error) { failed.push(error.unanswered ? `${label} (unanswered, whether it arrived is unknown: ${error.message})` : `${label} (${error.message})`); }
|
|
359
384
|
}
|
|
360
385
|
const thread = plan.thread ? ` Thread ${shortId(plan.thread.id)}.` : '';
|
|
361
386
|
let text;
|
|
@@ -409,7 +434,7 @@ export class MailSend {
|
|
|
409
434
|
if (error instanceof Refused) throw new Error(`${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
410
435
|
throw error;
|
|
411
436
|
}
|
|
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];
|
|
437
|
+
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
438
|
}
|
|
414
439
|
|
|
415
440
|
// ---- a filing --------------------------------------------------------------------------------------------------
|
|
@@ -504,12 +529,13 @@ export class MailSend {
|
|
|
504
529
|
try { how = await this.route(sent, receiver, door, true, params.notify_when_idle === true); }
|
|
505
530
|
catch (error) {
|
|
506
531
|
if (error instanceof Refused && error.kind === 'too_long') return refused('too_long', `the message is ${error.bytes} bytes; the limit is ${MAX_RELAYED_BYTES}`);
|
|
532
|
+
if (error.unanswered) return { ...refused('unanswered', unansweredText(`the Claude relay`, error.message, sent.id)), message_id: sent.id };
|
|
507
533
|
return refused('delivery_failed', error.message);
|
|
508
534
|
}
|
|
509
535
|
return {
|
|
510
|
-
delivered_to_bus: door.kind !== 'stored', message_id: sent.id, reply_to: sender,
|
|
536
|
+
delivered_to_bus: door.kind !== 'stored' && how !== 'pane_waits', message_id: sent.id, reply_to: sender,
|
|
511
537
|
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] },
|
|
538
|
+
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
539
|
};
|
|
514
540
|
}
|
|
515
541
|
|
|
@@ -673,6 +699,9 @@ export class MailSend {
|
|
|
673
699
|
return { outcome: 'delivered', detail: null };
|
|
674
700
|
} catch (error) {
|
|
675
701
|
// a message its door can never take (too long for a relay) stays filed for the session to read
|
|
702
|
+
// one whose hand-over is unanswered was not found where it would have landed: it stays unread and its wake stays,
|
|
703
|
+
// tried again after its backoff (a wake settled as filed would leave an idle receiver never told)
|
|
704
|
+
if (error.unanswered) return { outcome: 'later', detail: `unanswered: ${error.message}` };
|
|
676
705
|
return error instanceof Refused ? { outcome: 'filed', detail: error.kind } : { outcome: 'later', detail: error.message };
|
|
677
706
|
} finally {
|
|
678
707
|
const after = this.store.wakeState(address, id);
|
package/mail/store.mjs
CHANGED
|
@@ -397,6 +397,12 @@ export class MailStore extends EventEmitter {
|
|
|
397
397
|
renameSync(path, join(box.dir, 'cur', path.split(/[\\/]/).pop()));
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
+
/** A message filed read moved back to `new/`, unread, under its own name: a hand-over that may not have reached it. */
|
|
401
|
+
markUnread(address, path) {
|
|
402
|
+
const box = this.open(address);
|
|
403
|
+
renameSync(path, join(box.dir, 'new', path.split(/[\\/]/).pop()));
|
|
404
|
+
}
|
|
405
|
+
|
|
400
406
|
/** A wake kept beside the envelope (mailbox.rs `request_wake`): its watcher hands it to the session's door. */
|
|
401
407
|
requestWake(address, id) {
|
|
402
408
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('invalid wake message id');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.99",
|
|
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",
|