@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 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, or one `ps` on macOS), or null when it cannot be read. */
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) => execFile('ps', ['-o', 'ppid=', '-p', String(pid)], { timeout: 10_000 }, (error, stdout) => resolve(error ? null : Number(String(stdout).trim()) || 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)));
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
- 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())));
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
- for (const [address, name] of wasLive) if (!now.has(address)) void this.relays.retire(address, name).catch(() => {});
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 but Codex, whose rows come from the live map's Codex-only index ('codex-index' below)
822
- const HARNESSES = ['claude-code', 'pi', 'opencode', 'goose', 'gemini', 'grok', 'openclaw', 'hermes', 'supercode'];
823
- const isCodex = (key) => key.startsWith('codex:') || key.includes(':codex:');
824
- const applyRow = (change) => {
825
- if (change.kind === 'removed' && change.key) { this.sessionDescriptors?.delete(`sc:${machineName(this.env)}:${change.key.harness}:${change.key.session_id}`); locators.delete(`${change.key.harness}:${change.key.session_id}`); }
826
- else if (change.descriptor?.locator) { const { harness, session_id } = change.descriptor.locator; this.sessionDescriptors?.set(`sc:${machineName(this.env)}:${harness}:${session_id}`, change.descriptor); locators.set(`${harness}:${session_id}`, change.descriptor.locator); }
827
- };
828
- this.on('codex-index', ({ initial = null, changes = [] }) => {
829
- if (initial) {
830
- for (const key of [...locators.keys()]) if (isCodex(key)) locators.delete(key);
831
- for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (isCodex(key)) this.sessionDescriptors.delete(key);
832
- for (const descriptor of initial) applyRow({ descriptor });
833
- }
834
- for (const change of changes) applyRow(change);
835
- both();
836
- });
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
- // its own rows replaced; the Codex rows (the live map's index's) kept
861
- for (const key of [...locators.keys()]) if (!isCodex(key)) locators.delete(key);
862
- for (const key of [...(this.sessionDescriptors?.keys() ?? [])]) if (!isCodex(key)) this.sessionDescriptors.delete(key);
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) return;
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
- if (message.error) pending.reject(new Error(message.error.message ?? 'harness serve refused'));
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
- if (kind === 'claude-code') {
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
- const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
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 }, 15_000))
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 }, 15_000); return; } catch { /* the other 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 }, 15_000); }
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 (harness !== 'claude-code' || running(represented)) continue;
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 }, 15_000); }
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 }, 15_000); } catch { /* said below */ }
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
- if (pid && !alive(pid)) finish({ delivered: false, detail: 'the Claude relay process exited before confirming the send' });
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
- try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
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
- try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); step('turn handed in (send_input)'); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); step('Claude\'s receipt'); }
347
- catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
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
- 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'],
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
- if (!relayed.delivered) { if (filedHere) { try { unlinkSync(filed); } catch { /* gone */ } } throw new Error(relayed.detail); }
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.97",
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",