@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 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
- watcher = watch(folder, (_event, name) => (name ? onFile(String(name)) : readAll()));
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) { this.claude.delete(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
- if (Number.isInteger(server) && server > 0) this.markers.set(pid, server); else this.markers.delete(pid);
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 but Codex, whose rows come from the live map's Codex-only index ('codex-index' below)
802
- const HARNESSES = ['claude-code', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
803
- const isCodex = (key) => key.startsWith('codex:') || key.includes(':codex:');
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
- // its own rows replaced; the Codex rows (the live map's index's) kept
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) return;
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
- if (kind === 'claude-code') {
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
- const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
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, when it answers (claude_relay.rs `relay_endpoint`; the native watch serves it until
133
- * step 4, its port in `<mail>/relay-endpoint.json`). */
134
- async endpoint() {
135
- const port = readJson(join(this.mailRoot, 'relay-endpoint.json'))?.port;
136
- if (!Number.isInteger(port)) return null;
137
- return (await answers(port)) ? `http://127.0.0.1:${port}` : null;
138
- }
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 answering; the machine daemon\'s `supercode message watch` serves it (see mail/machine-daemon.log)');
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 { this.#cap(); }
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
- stored: [EXIT_STORED, 'Stored (not failed): it has no delivery door, so it sees this only if it runs supercode message inbox (a Codex session gets one with: supercode message setup codex). Don\'t resend, and don\'t wait for a reply'],
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.96",
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",