@volter/supercode-teams 0.3.90 → 0.3.92

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.
@@ -19,6 +19,22 @@ import { NativeEffects } from './native-effects.mjs';
19
19
  import { consumeAgentClosures } from './agent-closures.mjs';
20
20
 
21
21
  const SYNC_PROBLEM_LIMIT=20;
22
+ // How long after a session is first seen a change has its launch agent read again (a launch record written late).
23
+ const LAUNCH_AGENT_WINDOW_MS=120000;
24
+ /**
25
+ * The agent variables a session's launch recorded in its harness home (`<home>/supercode/launches/<session id>.json`,
26
+ * crates/harness/src/launch_agent.rs `read`), read here with no request: a Codex rollout's home is the folder above its
27
+ * `sessions/`, a Claude transcript's the folder above its `projects/`. Null when its launch named no agent.
28
+ */
29
+ export function readLaunchAgent(locator){
30
+ const id=locator?.session_id,path=locator?.storage?.kind==='file'?locator.storage.path:null;
31
+ const store={codex:'sessions','claude-code':'projects'}[locator?.harness];
32
+ if(typeof path!=='string'||!store||typeof id!=='string'||!/^[A-Za-z0-9_-]+$/.test(id))return null;
33
+ let home=null;for(let dir=dirname(path);dirname(dir)!==dir;dir=dirname(dir))if(basename(dir)===store){home=dirname(dir);break;}
34
+ if(!home)return null;
35
+ try{const environment=JSON.parse(readFileSync(join(home,'supercode','launches',`${id}.json`),'utf8'))?.environment;return environment&&typeof environment==='object'&&!Array.isArray(environment)?environment:null;}
36
+ catch{return null;}
37
+ }
22
38
  const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
23
39
  // Whether a root covers a session's folder: the root's own, or (unless the root is exact-only) one under it, by real path.
24
40
  const realOr=path=>{try{return realpathSync(path);}catch{return path;}};
@@ -638,8 +654,11 @@ export class TeamsConnector {
638
654
  // the binding is written only while this work is still the session's (checked again after its awaits)
639
655
  const bindingRead=this.clock.nowMs();
640
656
  try{await this.nativeEffects.discover(descriptor,ruleId,()=>this.sessionLive(key,ruleId,epoch)&&!((this.runtimeEnded?.get(key)??-Infinity)>=bindingRead));}catch(error){this.lastError=error;}
641
- // The agent this session's launch named, read when first seen here and whenever it changes, until queued for binding.
642
- if(captured&&!this.state.agent_sessions?.[key]&&(changed||!this.launchChecked?.has(key))){(this.launchChecked??=new Set()).add(key);await this.noteLaunchAgent(locator,()=>this.sessionLive(key,ruleId,epoch));}
657
+ // The agent this session's launch named, read (in this process, one file) when first seen here, and on a change in
658
+ // the LAUNCH_AGENT_WINDOW_MS after (a launch record written late), until queued for binding. A launch's agent is
659
+ // fixed at its launch: read on every change it was a call into harness serve's loop per transcript write.
660
+ const firstSeen=this.launchChecked?.get(key),now=this.clock.nowMs();
661
+ if(captured&&!this.state.agent_sessions?.[key]&&(firstSeen===undefined||(changed&&now-firstSeen<LAUNCH_AGENT_WINDOW_MS))){if(firstSeen===undefined)(this.launchChecked??=new Map()).set(key,now);this.noteLaunchAgent(locator,()=>this.sessionLive(key,ruleId,epoch));}
643
662
  // A session seen with its native bundle still owed (from before this start) waits for its file to go quiet; one
644
663
  // captured now has its wait armed by the commit that records it (flushOutbox).
645
664
  if(!changed)this.armNativeIdle(key);
@@ -1209,11 +1228,9 @@ export class TeamsConnector {
1209
1228
  if(error.status!==409&&error.status!==404)throw error;
1210
1229
  }
1211
1230
  }
1212
- /** The agent a session's launch named, as its home recorded it (`harness.v1.sessions.launch_agent`): queued for binding. */
1213
- async noteLaunchAgent(locator,live=()=>true){
1214
- if(typeof this.daemon?.launchAgent!=='function')return;
1215
- let environment;
1216
- try{environment=await this.daemon.launchAgent(locator);}catch(error){this.lastError=error;return;}
1231
+ /** The agent a session's launch named, as its home recorded it (read here, readLaunchAgent): queued for binding. */
1232
+ noteLaunchAgent(locator,live=()=>true){
1233
+ const environment=readLaunchAgent(locator);
1217
1234
  if(!live())return;
1218
1235
  if(environment?.SUPERCODE_AGENT_ID)this.queueAgentSession(`${locator.harness}:${locator.session_id}`,environment);
1219
1236
  }
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, renameSync, watch, writeFileSync } from 'node:fs';
17
+ import { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, 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';
@@ -30,6 +30,9 @@ const HISTORY_KEPT = 64;
30
30
  /** The argument a Codex hook file carries when supercode's mail hook is installed. */
31
31
  const CODEX_HOOK = 'message hook codex';
32
32
 
33
+ /** How long the runtime probes of one claim may take in all (a host stalled by load answers late, not never). */
34
+ const CLAIM_PROBE_MS = 10_000;
35
+
33
36
  const alive = (pid) => {
34
37
  if (!Number.isInteger(pid) || pid <= 0) return false;
35
38
  try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
@@ -50,6 +53,9 @@ function linuxStart(pid) {
50
53
  } catch { return null; }
51
54
  }
52
55
 
56
+ /** `path` with its symlinks resolved, or null when it cannot be (gone, or not a string). */
57
+ function realPath(path) { try { return typeof path === 'string' ? realpathSync(path) : null; } catch { return null; } }
58
+
53
59
  /** When the process on `pid` started, in ms (Linux's /proc, or one `ps` on macOS), or null when it cannot be read. */
54
60
  function startedMs(pid) {
55
61
  if (process.platform === 'linux') { const started = linuxStart(pid); return Promise.resolve(started ? Date.parse(`${started.date} UTC`) : null); }
@@ -62,11 +68,11 @@ function startedMs(pid) {
62
68
 
63
69
  /** Whether the runtime a receipt names answers at its address as that runtime (its own identity, with the receipt's
64
70
  * token): the proof a start read off the wall clock cannot give, as the clock may have stepped since the receipt.
65
- * true or false, or null when it did not answer within 2 s (the request is aborted then): not known either way. */
66
- async function answersAs(receipt) {
71
+ * true or false, or null when it did not answer within `boundMs` (the request is aborted then): not known either way. */
72
+ async function answersAs(receipt, boundMs = 2000) {
67
73
  try {
68
74
  const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `live-${receipt.receipt_id}`, permissions: ['observe'] });
69
- return (await client.describe({ signal: AbortSignal.timeout(2000) }))?.session_id === receipt.runtime_session_id;
75
+ return (await client.describe({ signal: AbortSignal.timeout(boundMs) }))?.session_id === receipt.runtime_session_id;
70
76
  } catch (error) {
71
77
  if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return null;
72
78
  return false;
@@ -99,6 +105,9 @@ export class LiveSessions extends EventEmitter {
99
105
  this.runtimes = new Map(); // receipt id → { pid, harness, sessionId, runtimeSessionId, cwd }
100
106
  this.markers = new Map(); // session pid → server pid
101
107
  this.codexRows = new Map(); // session id → { cwd, updatedAt }
108
+ // whether the Codex sessions are known yet: the index's first answer read, then their activity followed once after
109
+ // it (until then a Codex session missing from the map is not yet known, never not running)
110
+ this.codexIndexRead = false; this.codexKnown = false; this.codexWaiters = [];
102
111
  this.codexRunning = new Map(); // session id → turn (from activity)
103
112
  this.codexPaneOf = new Map(); // session id → pane
104
113
  this.projectHooks = new Map(); // directory → whether its .codex/hooks.json carries the hook
@@ -320,7 +329,8 @@ export class LiveSessions extends EventEmitter {
320
329
  const source = receipt.source;
321
330
  const entry = { pid: receipt.pid, harness: source.harness, sessionId: source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof source.workspace === 'string' ? source.workspace : null,
322
331
  // what a later check of the same pid needs: when the receipt was written, and how to ask the runtime itself
323
- created: Number(receipt.created_at_ms), probe: { base_url: receipt.base_url, token: receipt.token, receipt_id: id, runtime_session_id: receipt.runtime_session_id } };
332
+ // its workspace as a real path too, so a relay folder named through a symlinked home still matches it
333
+ realCwd: realPath(source.workspace), created: Number(receipt.created_at_ms), probe: { base_url: receipt.base_url, token: receipt.token, receipt_id: id, runtime_session_id: receipt.runtime_session_id } };
324
334
  const known = this.runtimes.get(id);
325
335
  if (known && known.pid === entry.pid && known.startChecked) entry.startChecked = true;
326
336
  this.runtimes.set(id, entry);
@@ -418,6 +428,26 @@ export class LiveSessions extends EventEmitter {
418
428
 
419
429
  #codexChanged() { this.emit('codex-followed'); }
420
430
 
431
+ /** The session index's first answer is read: its Codex sessions' activity is followed next (even when it has none). */
432
+ codexIndexed() { this.codexIndexRead = true; this.#codexChanged(); }
433
+
434
+ /** The Codex sessions' activity was followed: after the index's first answer, the Codex sessions are known. */
435
+ codexFollowedNow() {
436
+ if (!this.codexIndexRead || this.codexKnown) return;
437
+ this.codexKnown = true;
438
+ for (const resolve of this.codexWaiters.splice(0)) resolve(true);
439
+ }
440
+
441
+ /** Resolves true once the Codex sessions are known, or false when `boundMs` passes first. */
442
+ whenCodexKnown(boundMs) {
443
+ if (this.codexKnown) return Promise.resolve(true);
444
+ return new Promise((resolve) => {
445
+ const timer = setTimeout(() => { this.codexWaiters = this.codexWaiters.filter((waiter) => waiter !== done); resolve(false); }, boundMs);
446
+ const done = (value) => { clearTimeout(timer); resolve(value); };
447
+ this.codexWaiters.push(done);
448
+ });
449
+ }
450
+
421
451
  #changed() { this.emit('changed'); }
422
452
 
423
453
  // ---- what it holds ------------------------------------------------------------------------------------------------
@@ -467,6 +497,13 @@ export class LiveSessions extends EventEmitter {
467
497
  * file and checked as live_runtime.rs `resolve_live_runtime` checks it (its schema, a loopback address, its process
468
498
  * alive, the source it names): the bearer token a delivery uses is read here, at the delivery, never kept in the map.
469
499
  */
500
+ /** The live runtimes of `harness` whose workspace is `cwd`, each by the ids it may be closed by (in-memory, no read). */
501
+ runtimesIn(harness, cwd) {
502
+ const real = realPath(cwd);
503
+ return [...this.runtimes.values()].filter((runtime) => runtime.harness === harness && (runtime.cwd === cwd || (real && runtime.realCwd === real)) && alive(runtime.pid))
504
+ .map((runtime) => [...new Set([runtime.sessionId, runtime.runtimeSessionId].filter((id) => typeof id === 'string' && id))]);
505
+ }
506
+
470
507
  runtimeReceipt(harness, id) {
471
508
  for (const [receiptId, runtime] of this.runtimes) {
472
509
  if (runtime.harness !== harness || (runtime.sessionId !== id && runtime.runtimeSessionId !== id) || !alive(runtime.pid)) continue;
@@ -511,12 +548,14 @@ export class LiveSessions extends EventEmitter {
511
548
 
512
549
  /** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
513
550
  async #sessionOfClaim({ pid, ppid, session, runtime }) {
551
+ // the probes of one claim share one bound (CLAIM_PROBE_MS), however many of its processes are runtimes
552
+ const deadline = Date.now() + CLAIM_PROBE_MS;
514
553
  // a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
515
554
  // the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
516
555
  if (runtime === true) {
517
556
  // a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
518
557
  let own = null;
519
- try { own = await this.#runtimeOfPid(Number(pid)); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
558
+ try { own = await this.#runtimeOfPid(Number(pid), deadline); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
520
559
  if (own) return own;
521
560
  }
522
561
  const parent = this.claude.get(Number(ppid));
@@ -530,24 +569,27 @@ export class LiveSessions extends EventEmitter {
530
569
  // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
531
570
  // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
532
571
  for (const candidate of [Number(pid), Number(ppid)]) {
533
- const found = await this.#runtimeOfPid(candidate);
572
+ const found = await this.#runtimeOfPid(candidate, deadline);
534
573
  if (found) return found;
535
574
  }
536
575
  return null;
537
576
  }
538
577
 
539
578
  /** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
540
- async #runtimeOfPid(pid) {
579
+ async #runtimeOfPid(pid, deadline = Date.now() + CLAIM_PROBE_MS) {
541
580
  if (!Number.isInteger(pid) || pid <= 0) return null;
542
581
  const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
543
582
  if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
544
583
  if (held.length !== 1) return null;
545
584
  // a caller's identity: checked again now, with no process (the runtime asked at its own address), as a runtime that
546
585
  // ended may have left its pid to another process before the sweep lets its receipt go
547
- const answered = await answersAs(held[0].probe);
548
- // one that did not answer in time is not known either way: refused, never taken for its parent's session or its
586
+ // a host stalled by load answers late, not never: one that did not answer in 2 s is asked once more, for what is left
587
+ // of the claim's bound
588
+ let answered = await answersAs(held[0].probe, Math.max(1, Math.min(2000, deadline - Date.now())));
589
+ if (answered === null && deadline - Date.now() > 250) answered = await answersAs(held[0].probe, deadline - Date.now());
590
+ // one that did not answer then is not known either way: refused, never taken for its parent's session or its
549
591
  // environment's (which would name another caller)
550
- if (answered === null) throw Object.assign(new CallerRefused(`process ${pid} is a runtime that did not answer within 2 s, so which session is calling was not confirmed; nothing was done`), { unconfirmed: true });
592
+ if (answered === null) throw Object.assign(new CallerRefused(`process ${pid} is a runtime that did not answer within ${CLAIM_PROBE_MS / 1000} s, so which session is calling was not confirmed; nothing was done`), { unconfirmed: true });
551
593
  if (!answered) return null;
552
594
  return this.#address(held[0].harness, held[0].sessionId);
553
595
  }
package/machine.mjs CHANGED
@@ -594,7 +594,7 @@ export class TeamsMachine extends MachineCore {
594
594
  const followNow = async () => {
595
595
  const locators = this.live.codexFollowed();
596
596
  const key = locators.map((locator) => locator.session_id).sort().join('\n');
597
- if (key === followed) return;
597
+ if (key === followed) { this.live.codexFollowedNow(); return; }
598
598
  const previous = this.liveActivity;
599
599
  try {
600
600
  if (locators.length) {
@@ -603,6 +603,7 @@ export class TeamsMachine extends MachineCore {
603
603
  this.live.codexActivity(opened?.initial ?? [], { replace: true });
604
604
  } else { this.liveActivity = null; this.live.codexActivity([], { replace: true }); }
605
605
  followed = key; backoffMs = 0;
606
+ this.live.codexFollowedNow();
606
607
  if (previous) void this.#serveRequest('harness.v1.sessions.activity.unsubscribe', { subscription: previous }, 30_000).catch(() => {});
607
608
  } catch (error) {
608
609
  backoffMs = Math.min(300_000, Math.max(2000, (backoffMs || 1000) * 2));
@@ -645,6 +646,7 @@ export class TeamsMachine extends MachineCore {
645
646
  recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
646
647
  this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
647
648
  runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
649
+ runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
648
650
  log: (line) => this.emit('mail-log', line) });
649
651
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
650
652
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
@@ -794,6 +796,7 @@ export class TeamsMachine extends MachineCore {
794
796
  this.sessionDescriptors?.clear();
795
797
  for (const descriptor of answer?.initial ?? []) if (descriptor?.locator) { locators.set(`${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor.locator); this.live?.codexIndex({ descriptor }); this.sessionDescriptors?.set(`sc:${machineName(this.env)}:${descriptor.locator.harness}:${descriptor.locator.session_id}`, descriptor); }
796
798
  producer.note('sessions_index', null); noteIndexParts(answer?.failed, null); both();
799
+ this.live?.codexIndexed();
797
800
  }, (error) => {
798
801
  indexBackoff = backoff(indexBackoff);
799
802
  const why = `the session index could not be subscribed: ${error.message}; sessions and panes change on pane and mail events only until it is`;
package/mail/relay.mjs CHANGED
@@ -25,6 +25,8 @@ 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 whose runtime start lost its answer is looked at for a runtime that start left running. */
29
+ const LOST_START_KEPT_MS = 5 * 60_000;
28
30
 
29
31
  const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
30
32
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
@@ -61,8 +63,9 @@ export class Relays {
61
63
  * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
62
64
  * `runtimeNamed(receiptId)` one receipt read by its own name.
63
65
  */
64
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, log = () => {} }) {
65
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, log });
66
+ constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], log = () => {} }) {
67
+ Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, log });
68
+ this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
66
69
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
67
70
  this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
68
71
  }
@@ -78,7 +81,25 @@ export class Relays {
78
81
  // closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
79
82
  // again, never handing its turn to a runtime that is closing
80
83
  void this.#locked(directory, () => this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, 15_000))
81
- .catch((error) => this.log(`relay ${directory.split(/[\\/]/).pop()} beyond the ${RELAYS_RUNNING} most recent was not closed: ${error.message}`));
84
+ .catch((error) => {
85
+ // still running, so still counted: the next cap closes it again (unless a send started it anew meanwhile)
86
+ if (!this.running.has(directory)) this.running.set(directory, relay);
87
+ this.log(`relay ${directory.split(/[\\/]/).pop()} beyond the ${RELAYS_RUNNING} most recent was not closed: ${error.message}`);
88
+ });
89
+ }
90
+ // a start whose answer was lost may have left a runtime running in its relay's folder that no record names: closed by
91
+ // its id once the live-session map holds it (looked for over five minutes: the start may finish after its answer)
92
+ for (const [directory, lostAt] of this.lost) {
93
+ if (Date.now() - lostAt > LOST_START_KEPT_MS) { this.lost.delete(directory); continue; }
94
+ if (this.sending.has(directory)) continue;
95
+ const named = readJson(join(directory, 'relay.json'))?.runtime_id;
96
+ for (const ids of this.runtimesIn('claude-code', directory)) {
97
+ if (ids.includes(named)) continue;
98
+ void this.#locked(directory, async () => {
99
+ for (const id of ids) { try { await this.serve('harness.v1.runtimes.close', { runtime_id: id }, 15_000); return; } catch { /* the other id */ } }
100
+ this.log(`relay ${directory.split(/[\\/]/).pop()}: a runtime its lost start left running was not closed`);
101
+ });
102
+ }
82
103
  }
83
104
  }
84
105
 
@@ -165,7 +186,7 @@ export class Relays {
165
186
  catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
166
187
  }
167
188
  const key = spec.directory.split(/[\\/]/).pop();
168
- const started = await this.serve('harness.v1.runtimes.start', {
189
+ const started = await this.#start(spec, {
169
190
  harness: 'claude-code',
170
191
  launch: {
171
192
  program: 'claude',
@@ -270,6 +291,13 @@ export class Relays {
270
291
  }
271
292
  }
272
293
 
294
+ /** `runtimes.start` for a relay: a start that fails (its answer lost, or late past its bound) may still have left a
295
+ * runtime running, so its folder is looked at by the next caps (#cap). */
296
+ async #start(spec, params, timeoutMs) {
297
+ try { return await this.serve('harness.v1.runtimes.start', params, timeoutMs); }
298
+ catch (error) { this.lost.set(spec.directory, Date.now()); throw error; }
299
+ }
300
+
273
301
  /** A relay this daemon runs, used now. */
274
302
  #used(spec, connection) {
275
303
  const runtimeId = readJson(spec.record)?.runtime_id;
package/mail/send.mjs CHANGED
@@ -23,6 +23,9 @@ export function unansweredText(where, detail, id) {
23
23
  return `Unanswered: ${where} took the message, but its answer was lost (${detail}), so whether it was delivered is unknown. Message id ${shortId(id)}. Don't send it again as a new message; supercode message show ${shortId(id)} says where it stands.`;
24
24
  }
25
25
 
26
+ /** How long a send waits, in a daemon's first moments, for its Codex sessions to be known before it resolves a receiver. */
27
+ const CODEX_KNOWN_WAIT_MS = 20_000;
28
+
26
29
  /** Replies in one unbroken chain between two sessions beyond which another is refused. */
27
30
  const REPLY_CHAIN_LIMIT = 32;
28
31
  /** How long a name no running session carries still reaches the session that last carried it. */
@@ -148,7 +151,7 @@ export class MailSend {
148
151
  }
149
152
  if (parsed) {
150
153
  const session = sessions.find((row) => row.address === parsed.address);
151
- if (!session) return { outcome: outcome(EXIT_UNKNOWN, `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
154
+ if (!session) return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(to) ?? `${to} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`) };
152
155
  return { address: session.address, name: session.name, session };
153
156
  }
154
157
  // a name: unique among the sessions here, else the session it last named (the names ledger)
@@ -176,7 +179,17 @@ export class MailSend {
176
179
  }).slice(0, 3).map((row) => `${row.name} (${row.address.split(':')[2]}, ${row.status})`);
177
180
  if (near.length) hint = ` Did you mean: ${near.join(', ')}?`;
178
181
  }
179
- return { outcome: outcome(EXIT_UNKNOWN, `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
182
+ return { outcome: outcome(EXIT_UNKNOWN, this.#notYetKnown(wanted) ?? `No session named "${wanted}" is reachable.${hint} Run supercode message list. Nothing was sent.`) };
183
+ }
184
+
185
+ /** In a daemon's first moments the Codex sessions are not known yet (its session index and their activity still being
186
+ * read): a send waits for them, at most CODEX_KNOWN_WAIT_MS. */
187
+ async #codexKnown() { if (!this.live.codexKnown) await this.live.whenCodexKnown?.(CODEX_KNOWN_WAIT_MS); }
188
+
189
+ /** What a receiver not found is, while the Codex sessions are not known yet: not known yet, never not running. */
190
+ #notYetKnown(what) {
191
+ if (this.live.codexKnown !== false) return null;
192
+ return `${what} is not known yet: this machine's daemon started a moment ago and is still reading its sessions (its Codex sessions among them). Nothing was sent. Send it again in a minute.`;
180
193
  }
181
194
 
182
195
  /** The door that reaches `address` here, from one read of the live sessions (mail_route.rs `door_in`): null when no
@@ -195,6 +208,7 @@ export class MailSend {
195
208
 
196
209
  /** Deliver from `caller` to `to` on this machine (mail_send.rs `deliver`). */
197
210
  async #deliver(caller, to, body, { subject, in_reply_to, notify_when_idle, queue, id, notice, thread: carried, chosen }, link) {
211
+ await this.#codexKnown();
198
212
  const sessions = this.live.sessions();
199
213
  const receiver = this.#receiver(to, sessions);
200
214
  if (receiver.outcome) return receiver.outcome;
@@ -226,7 +240,7 @@ export class MailSend {
226
240
  if (plan.plan) return this.#deliverPlanned(caller, sent, plan.plan, { queue, notify_when_idle });
227
241
  // the thread this message is in, kept in the send log: a later reply to it continues that thread
228
242
  const sentIn = threadOf(sent);
229
- if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
243
+ if (!door || door.kind === 'other_machine') return outcome(EXIT_UNKNOWN, this.#notYetKnown(name) ?? `${name} is no longer running. Nothing was sent. Run supercode message list for the live sessions.`);
230
244
  const idleNote = notify_when_idle && cameThroughLink(sent)
231
245
  ? ' No idle notice: this message came through a link, so its return address is as claimed, and nothing is sent to it on its own (D200); a reply is the receiver\'s own send.'
232
246
  : notify_when_idle ? ' Subscribed: one idle notice reaches you when its next turn ends.' : '';
@@ -444,6 +458,7 @@ export class MailSend {
444
458
  const receiver = typeof harness === 'string' && typeof sessionId === 'string' ? makeAddress(this.machine, harness, sessionId) : null;
445
459
  if (!receiver) return refused('delivery_failed', `\`sc:${this.machine}:${harness}:${sessionId}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>`);
446
460
  if (params.voice_for != null && params.voice_for !== receiver) return refused('invalid_sender', 'a voice front must represent the receiving session');
461
+ await this.#codexKnown();
447
462
  const sessions = this.live.sessions();
448
463
  // a Claude session's registry name must still name only that session; a holder the mailbox launched may have stopped
449
464
  if (harness === 'claude-code' && !(await this.agents.resumable(receiver)) && !this.live.runtimeReceipt('claude-code', sessionId)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.90",
3
+ "version": "0.3.92",
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",