@volter/supercode-teams 0.3.97 → 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
@@ -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
@@ -106,6 +106,8 @@ const INITIAL_INPUT_READY_DEADLINE_MS = 90_000;
106
106
  const INITIAL_INPUT_POLL_MS = 250;
107
107
  /** The pause between the two composer readings a user's turn waits on. */
108
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']);
109
111
  // Claude Code waits out a usage limit and queues every message typed meanwhile until the limit's reset ("new messages
110
112
  // wait for your usage limit to reset"; esc or /rate-limit-options ends the wait). A limit raised early, or a weekly
111
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
@@ -547,6 +549,7 @@ export class TeamsMachine extends MachineCore {
547
549
  // ---- lifecycle ------------------------------------------------------------
548
550
 
549
551
  async beforeListen() {
552
+ this.bornAt ??= Date.now();
550
553
  mkdirSync(this.portsDir, { recursive: true }); // the port table's announce directory exists before any pane launches
551
554
  this.#startServe();
552
555
  if (this.#pendingNotices().length) this.#watchEnds();
@@ -623,7 +626,6 @@ export class TeamsMachine extends MachineCore {
623
626
  if (change.kind === 'removed' && change.key?.harness === 'codex') this.live.codexIndex({ removed: change.key.session_id });
624
627
  else if (change.descriptor) this.live.codexIndex({ descriptor: change.descriptor });
625
628
  }
626
- this.emit('codex-index', { changes: params.changes ?? [] });
627
629
  }
628
630
  });
629
631
  // The Codex sessions' own index (Codex rollouts only): the live map knows them from its first answer, never waiting on
@@ -636,9 +638,6 @@ export class TeamsMachine extends MachineCore {
636
638
  this.live.codexRowsReplaced();
637
639
  for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) this.live.codexIndex({ descriptor });
638
640
  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
641
  }, (error) => {
643
642
  codexIndexBackoff = Math.min(300_000, Math.max(2000, (codexIndexBackoff || 1000) * 2));
644
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`);
@@ -713,7 +712,8 @@ export class TeamsMachine extends MachineCore {
713
712
  // known view stands until they are known again
714
713
  if (!live.codexKnown) return;
715
714
  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(() => {});
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
717
  wasLive = now;
718
718
  });
719
719
  void live.whenCodexKnown(120_000).then((known) => {
@@ -775,6 +775,8 @@ export class TeamsMachine extends MachineCore {
775
775
  const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
776
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 }));
777
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 })));
778
780
  // the harness session under each live pane, as the board reads it: unread is said, never no session
779
781
  let activities = [], sessions_unread = null;
780
782
  if (live.length) {
@@ -818,22 +820,9 @@ export class TeamsMachine extends MachineCore {
818
820
  // is back, and holds its own retry (2 s doubling to 5 min) until it is (MR4); nothing reads its fields as current.
819
821
  const backoff = (previous) => Math.min(300_000, Math.max(2000, (previous || 1000) * 2));
820
822
  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
- });
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'];
837
826
  const locators = new Map(); // `${harness}:${session_id}` → the index's locator for it
838
827
  const noteIndexParts = (failed, error) => {
839
828
  // the index's own unread parts (a store, a folder, a registry it could not read): their sessions are unknown
@@ -856,10 +845,8 @@ export class TeamsMachine extends MachineCore {
856
845
  let indexBackoff = 0;
857
846
  const subscribe = () => this.#serveRequest('harness.v1.sessions.index.subscribe', { harnesses: HARNESSES, limit: 0 }, 60_000)
858
847
  .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);
848
+ this.recordsIndex = answer?.subscription ?? null; indexBackoff = 0; locators.clear();
849
+ this.sessionDescriptors?.clear();
863
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); }
864
851
  producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
865
852
  }, (error) => {
@@ -1047,8 +1034,11 @@ export class TeamsMachine extends MachineCore {
1047
1034
  const serveLines = lineSplitter();
1048
1035
  // a serve already replaced never answers into the streams that its successor serves (its late answers would reach a
1049
1036
  // reopened stream that uses the same ids)
1037
+ let answered = false;
1050
1038
  child.stdout.on('data', (chunk) => {
1051
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)`); }
1052
1042
  for (const line of serveLines(chunk)) if (line) this.#routeServeLine(line);
1053
1043
  });
1054
1044
  child.stderr.setEncoding('utf8');
@@ -1140,7 +1130,13 @@ export class TeamsMachine extends MachineCore {
1140
1130
  const entry = this.connections.get(conn);
1141
1131
  if (conn === INTERNAL_CONNECTION) {
1142
1132
  const pending = this.internalRequests?.get(original);
1143
- 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
+ }
1144
1140
  this.internalRequests.delete(original);
1145
1141
  // the daemon's own subscriptions (its machine record's session index) are routed back to it, as an operator's are
1146
1142
  if (message.result && typeof message.result === 'object') {
@@ -2228,15 +2224,19 @@ export class TeamsMachine extends MachineCore {
2228
2224
  if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
2229
2225
  return { refused: { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` } };
2230
2226
  }
2231
- 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) {
2232
2231
  // A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
2233
2232
  // still holds refuses the turn at once and the session waits again, for the next turn to try.
2234
2233
  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 }))) {
2234
+ if (draftKind === 'claude-code' && !this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
2236
2235
  await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
2237
2236
  await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
2238
2237
  }
2239
- 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 }));
2240
2240
  const first = await draftOf();
2241
2241
  await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
2242
2242
  const second = await draftOf();
package/mail/relay.mjs CHANGED
@@ -28,6 +28,8 @@ export const RELAYS_RUNNING = 4;
28
28
  /** How long a relay speaking for a sender on another machine is kept after its last send (it carries that sender's
29
29
  * mail while it is in use; retired after every send, each message from it started a relay). */
30
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;
31
33
  /** How long a relay whose runtime start lost its answer is looked at for a runtime that start left running. */
32
34
  const LOST_START_KEPT_MS = 5 * 60_000;
33
35
 
@@ -172,7 +174,12 @@ export class Relays {
172
174
  if (machine !== this.machine) {
173
175
  let last = 0; try { last = statSync(join(directory, 'receipt.json')).mtimeMs; } catch { /* never sent */ }
174
176
  if (Date.now() - last < REMOTE_RELAY_KEPT_MS) { this.#keepRemote(directory, last); continue; }
175
- } else if (harness !== 'claude-code' || running(represented)) 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;
176
183
  // only a Claude session is provably ended when the map does not hold it (its registry lists every live Claude
177
184
  // process); a sender of another harness the map may not see (Grok, Gemini, a Codex session idle a day) is kept
178
185
  if (await this.#retireFolder(directory, represented)) retired++;
@@ -321,7 +328,6 @@ export class Relays {
321
328
  */
322
329
  async send(sender, senderName, receiver, message, messageId) {
323
330
  const spec = this.spec(sender, senderName);
324
- mkdirSync(spec.directory, { recursive: true });
325
331
  const asked = performance.now();
326
332
  try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
327
333
  finally {
@@ -332,6 +338,8 @@ export class Relays {
332
338
 
333
339
  async #send(spec, receiver, message, messageId, asked) {
334
340
  try {
341
+ // made under its lock: a retire that held the lock meanwhile may have removed it
342
+ mkdirSync(spec.directory, { recursive: true });
335
343
  const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
336
344
  const steps = [], began = asked;
337
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.97",
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",