@volter/supercode-teams 0.3.87 → 0.3.89

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/bin/teams.mjs CHANGED
@@ -174,6 +174,7 @@ async function main() {
174
174
  // --verbose narrates every door with its latency, so a slow machine is diagnosed from its own log.
175
175
  machine.on('door', (d) => { if (flags.verbose) process.stderr.write(`door ${d.method} ${d.outcome} ${d.ms}ms${d.detail ? ` ${d.detail}` : ''}\n`); });
176
176
  machine.on('serve-stderr', (t) => process.stderr.write(t));
177
+ machine.on('mail-log', (line) => process.stderr.write(`mail: ${line}\n`));
177
178
  machine.on('serve-exit', (e) => process.stderr.write(`harness serve exited: ${JSON.stringify(e)}\n`));
178
179
  machine.on('unrouted-notification', (m) => { if (flags.verbose) process.stderr.write(`unrouted: ${m.method}\n`); });
179
180
  const socket = await machine.start();
package/bin/workspace.mjs CHANGED
@@ -1135,6 +1135,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1135
1135
  // what ends the daemon's harness serve is said in the connector's log, as `machine start` says it
1136
1136
  machine.on('serve-exit', (exit) => process.stderr.write(`supercode teams: harness serve exited: ${JSON.stringify(exit)}\n`));
1137
1137
  machine.on('serve-stderr', (text) => process.stderr.write(`supercode teams: harness serve: ${String(text).trimEnd().slice(-2000)}\n`));
1138
+ machine.on('mail-log', (line) => process.stderr.write(`supercode teams: mail: ${line}\n`));
1138
1139
  // its mail goes through the daemon's own send, in this process
1139
1140
  machineEvents.send = (request) => machine.sendOwn(request);
1140
1141
  // the Claude worker homes the roots declare: their live sessions are this machine's too (the live-session map)
package/live-sessions.mjs CHANGED
@@ -17,6 +17,7 @@ import { execFile } from 'node:child_process';
17
17
  import { mkdirSync, readFileSync, readdirSync, renameSync, watch, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
+ import { FrontendClient } from '@volter/supercode-frontend';
20
21
 
21
22
  /** A relay registers in Claude's registry under this prefix; it is not a session. */
22
23
  const RELAY_PREFIX = 'sc-';
@@ -49,6 +50,27 @@ function linuxStart(pid) {
49
50
  } catch { return null; }
50
51
  }
51
52
 
53
+ /** When the process on `pid` started, in ms (Linux's /proc, or one `ps` on macOS), or null when it cannot be read. */
54
+ function startedMs(pid) {
55
+ if (process.platform === 'linux') { const started = linuxStart(pid); return Promise.resolve(started ? Date.parse(`${started.date} UTC`) : null); }
56
+ if (process.platform !== 'darwin') return Promise.resolve(null);
57
+ return new Promise((resolve) => execFile('ps', ['-o', 'lstart=', '-p', String(pid)], { env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, timeout: 10_000 }, (error, stdout) => {
58
+ const started = error ? null : String(stdout).replace(/\s+/g, ' ').trim();
59
+ resolve(started ? Date.parse(`${started} UTC`) : null);
60
+ }));
61
+ }
62
+
63
+ /** Whether the runtime a receipt names answers at its address as that runtime (its own identity, with the receipt's
64
+ * token): the proof a start read off the wall clock cannot give, as the clock may have stepped since the receipt. */
65
+ async function answersAs(receipt) {
66
+ try {
67
+ const client = new FrontendClient({ baseUrl: receipt.base_url, token: receipt.token, clientId: `live-${receipt.receipt_id}`, permissions: ['observe'] });
68
+ let timer;
69
+ const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(null), 2000); });
70
+ try { return (await Promise.race([client.describe(), bound]))?.session_id === receipt.runtime_session_id; } finally { clearTimeout(timer); }
71
+ } catch { return false; }
72
+ }
73
+
52
74
  /** Why a claim names no caller. */
53
75
  export class CallerRefused extends Error {
54
76
  constructor(message) { super(message); this.code = 403; }
@@ -284,8 +306,44 @@ export class LiveSessions extends EventEmitter {
284
306
  if (this.runtimes.delete(id)) this.#changed();
285
307
  return;
286
308
  }
287
- this.runtimes.set(id, { 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 });
309
+ this.#holdRuntime(id, receipt);
310
+ }
311
+
312
+ /**
313
+ * A live-runtime receipt held in the map, its process's start checked once (on Linux from /proc, on macOS by one `ps`,
314
+ * as a Claude entry's is): a process that started after its receipt was written is its pid used again, and the
315
+ * receipt is let go, never named as that runtime.
316
+ */
317
+ #holdRuntime(id, receipt) {
318
+ const source = receipt.source;
319
+ 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,
320
+ // what a later check of the same pid needs: when the receipt was written, and how to ask the runtime itself
321
+ 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 } };
322
+ const known = this.runtimes.get(id);
323
+ if (known && known.pid === entry.pid && known.startChecked) entry.startChecked = true;
324
+ this.runtimes.set(id, entry);
288
325
  this.#changed();
326
+ if (entry.startChecked || !Number.isFinite(entry.created)) return;
327
+ entry.startChecked = true;
328
+ void this.#samePid(entry).then((same) => {
329
+ if (same || this.runtimes.get(id) !== entry) return;
330
+ this.runtimes.delete(id);
331
+ this.log(`live sessions: the runtime receipt ${id} names pid ${entry.pid}, but that process started after the receipt was written and does not answer as the runtime: the pid was used again; the receipt is let go`);
332
+ this.#changed();
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Whether the process on a held runtime's pid is still the runtime its receipt names: one that started no later than
338
+ * the receipt was written (ps and /proc say a start to the second, so within 2 s), or, when its start reads later,
339
+ * one that answers as the runtime at the receipt's address (a wall clock that stepped forward makes a live runtime's
340
+ * start read late; a pid used again does not answer as it).
341
+ */
342
+ async #samePid(entry) {
343
+ if (!alive(entry.pid)) return false;
344
+ const started = await startedMs(entry.pid);
345
+ if (!Number.isFinite(started) || !Number.isFinite(entry.created) || started <= entry.created + 2000) return true;
346
+ return answersAs(entry.probe);
289
347
  }
290
348
 
291
349
  /** One messaging-tools marker: the session process named by its file, the server pid in it. */
@@ -425,10 +483,7 @@ export class LiveSessions extends EventEmitter {
425
483
  if (typeof receiptId !== 'string' || !/^[0-9a-f]+$/i.test(receiptId)) return null;
426
484
  const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
427
485
  if (!receipt || receipt.receipt_id !== receiptId || typeof receipt.source?.harness !== 'string' || typeof receipt.source?.session_id !== 'string') return null;
428
- if (!this.runtimes.has(receiptId)) {
429
- this.runtimes.set(receiptId, { pid: receipt.pid, harness: receipt.source.harness, sessionId: receipt.source.session_id, runtimeSessionId: typeof receipt.runtime_session_id === 'string' ? receipt.runtime_session_id : null, cwd: typeof receipt.source.workspace === 'string' ? receipt.source.workspace : null });
430
- this.#changed();
431
- }
486
+ if (!this.runtimes.has(receiptId)) this.#holdRuntime(receiptId, receipt);
432
487
  return receipt;
433
488
  }
434
489
 
@@ -453,7 +508,15 @@ export class LiveSessions extends EventEmitter {
453
508
  }
454
509
 
455
510
  /** The session a client runs in, from its claim: its parent's current session, else its environment's id through the map. */
456
- #sessionOfClaim({ pid, ppid, session }) {
511
+ async #sessionOfClaim({ pid, ppid, session, runtime }) {
512
+ // a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
513
+ // the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
514
+ if (runtime === true) {
515
+ // a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
516
+ let own = null;
517
+ try { own = await this.#runtimeOfPid(Number(pid)); } catch (error) { if (!(error instanceof CallerRefused)) throw error; }
518
+ if (own) return own;
519
+ }
457
520
  const parent = this.claude.get(Number(ppid));
458
521
  if (parent && alive(parent.pid)) return this.#address('claude-code', parent.sessionId);
459
522
  if (typeof session === 'string' && session) {
@@ -465,20 +528,30 @@ export class LiveSessions extends EventEmitter {
465
528
  // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
466
529
  // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
467
530
  for (const candidate of [Number(pid), Number(ppid)]) {
468
- if (!Number.isInteger(candidate) || candidate <= 0) continue;
469
- const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === candidate && alive(runtime.pid));
470
- if (held.length === 1) return this.#address(held[0].harness, held[0].sessionId);
471
- if (held.length > 1) throw new CallerRefused(`process ${candidate} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
531
+ const found = await this.#runtimeOfPid(candidate);
532
+ if (found) return found;
472
533
  }
473
534
  return null;
474
535
  }
475
536
 
537
+ /** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
538
+ async #runtimeOfPid(pid) {
539
+ if (!Number.isInteger(pid) || pid <= 0) return null;
540
+ const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
541
+ if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
542
+ if (held.length !== 1) return null;
543
+ // a caller's identity: checked again now, with no process (the runtime asked at its own address), as a runtime that
544
+ // ended may have left its pid to another process before the sweep lets its receipt go
545
+ if (!(await answersAs(held[0].probe))) return null;
546
+ return this.#address(held[0].harness, held[0].sessionId);
547
+ }
548
+
476
549
  /**
477
550
  * Who a local client is, from its claim `{pid, ppid, session, named}` (docs/architecture/overview.md, "Who is
478
551
  * calling"): a session's address, a board's address, or null for a person. Throws CallerRefused when the claim names
479
552
  * a caller the map does not hold.
480
553
  */
481
- callerOf(claim) {
554
+ async callerOf(claim) {
482
555
  const { pid, owner, named } = claim ?? {};
483
556
  const board = typeof named === 'string' ? new RegExp(`^sc:${this.machine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:([^:\\s]+)$`).exec(named) : null;
484
557
  // a board's owner, by the pid it registered with: the client itself, or the owner that started it as the board
@@ -487,7 +560,7 @@ export class LiveSessions extends EventEmitter {
487
560
  if (registered && [pid, Number(owner)].includes(registered) && alive(registered)) return named;
488
561
  // A board's name from anything but its owner is not taken (a card's session, a pane run for the board): the caller
489
562
  // is the session the claim resolves to, or a person, as before (a board's name stood only under its own process).
490
- const session = this.#sessionOfClaim(claim ?? {});
563
+ const session = await this.#sessionOfClaim(claim ?? {});
491
564
  if (session) return session;
492
565
  if (board) return null;
493
566
  // a verb a pane runs for the session that launched it names that session: accepted while it is live
package/machine.mjs CHANGED
@@ -36,6 +36,7 @@ import { RecordHolder, RecordProducer, RECENT_PER_MAILBOX, RECORDS_NOT_GRANTED }
36
36
  import { LiveSessions, ACTING_VARIABLES } from './live-sessions.mjs';
37
37
  import { MailStore } from './mail/store.mjs';
38
38
  import { MailReads, panePrompt } from './mail/reads.mjs';
39
+ import { render } from './mail/envelope.mjs';
39
40
  import { Agents } from './mail/agents.mjs';
40
41
  import { Relays } from './mail/relay.mjs';
41
42
  import { MailSend } from './mail/send.mjs';
@@ -635,6 +636,7 @@ export class TeamsMachine extends MachineCore {
635
636
  homes: { get claudeProjects() { return [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')); }, mailRoot },
636
637
  paneQuestion: async (pane) => { const session = await this.#paneByContext(pane); return session ? panePrompt(await this.host.capture(session.id, { lines: 60 })) : null; },
637
638
  askMachine: (machine, request) => this.#askMailDoor(machine, request),
639
+ log: (line) => this.emit('mail-log', line),
638
640
  });
639
641
  // a send to a session, for every entry (3b-1): its routing on the live map, an agent's plan and its doors
640
642
  const machine = machineName(this.env);
@@ -642,14 +644,26 @@ export class TeamsMachine extends MachineCore {
642
644
  discover: (params) => this.#serveRequest('harness.v1.sessions.discover', params, 10_000),
643
645
  recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
644
646
  this.relays = new Relays({ mailRoot, machine, supercodeBin: this.supercodeBin, serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
645
- runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId) });
647
+ runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
648
+ log: (line) => this.emit('mail-log', line) });
646
649
  this.mailSend = new MailSend({ machine, mailRoot, live, store: this.mailStore, agents: this.agents, relays: this.relays,
647
650
  // a send waits on its receiver's door there (a relay's receipt takes up to 90 s), never past MAIL_SEND_MS
648
651
  askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
649
- deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request) });
652
+ deliverRuntime: deliverToRuntime, codexAnswer: (request) => codexQuestions(request),
653
+ // a user's own turn typed into its pane, in process (as `panes.turn` types one)
654
+ typeTurn: async (pane, harness, text, messageIds) => {
655
+ const session = await this.#paneByContext(pane);
656
+ if (!session) throw new Error(`no pane with context ${pane}`);
657
+ const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
658
+ if (result.refused) throw new Error(result.refused.reason);
659
+ return result.waits ?? result;
660
+ },
661
+ log: (line) => this.emit('mail-log', line) });
650
662
  // an agent's verbs (3b-2): its threads, a delegation, its record
651
663
  this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
652
- paneOf: (session) => this.mail.paneOf(session), mailRoot });
664
+ paneOf: (session) => this.mail.paneOf(session), mailRoot,
665
+ // a delegation's pane that never became its holder is closed, as `panes.close` closes one
666
+ closePane: async (pane) => { const session = await this.#paneByContext(pane); if (session) { await this.host.closeSession(session.id); this.#panesChanged(); } } });
653
667
  // the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
654
668
  // runs under another, kept from the map's changes (at most once a second)
655
669
  let naming = null;
@@ -939,6 +953,8 @@ export class TeamsMachine extends MachineCore {
939
953
  this.mailWatch?.kill('SIGTERM');
940
954
  this.stoppingServe = true;
941
955
  clearTimeout(this.serveRestart);
956
+ // said, so a stop reads as one in the log and never as a serve that broke
957
+ if (this.serve) this.emit('serve-stderr', 'stopping harness serve: the daemon is stopping\n');
942
958
  this.serve?.kill('SIGTERM');
943
959
  this.host.dispose();
944
960
  }
@@ -985,7 +1001,9 @@ export class TeamsMachine extends MachineCore {
985
1001
  };
986
1002
  // a write into a serve that has gone (EPIPE, ECONNRESET) is its pipe's error: the serve is over, the daemon is not
987
1003
  child.stdin.on('error', (error) => {
988
- this.emit('serve-stderr', `harness serve's input: ${error.message}\n`);
1004
+ // a write that races a stop's SIGTERM is the stop's, already said
1005
+ if (this.stoppingServe) return;
1006
+ this.emit('serve-stderr', `harness serve's input: ${error.message} (pid ${child.pid}, up ${Math.round((Date.now() - started) / 1000)} s)\n`);
989
1007
  try { child.kill('SIGTERM'); } catch { /* gone */ }
990
1008
  // one that does not end on SIGTERM within 5 s is killed: a serve that no longer reads is never left behind
991
1009
  const grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch { /* gone */ } } }, 5000);
@@ -997,7 +1015,7 @@ export class TeamsMachine extends MachineCore {
997
1015
  child.stderr.on('error', () => {});
998
1016
  child.on('error', (error) => end(`could not run (${error.message})`));
999
1017
  child.on('exit', (code, signal) => {
1000
- this.emit('serve-exit', { code, signal });
1018
+ this.emit('serve-exit', { code, signal, pid: child.pid, up_s: Math.round((Date.now() - started) / 1000), stopping: this.stoppingServe === true });
1001
1019
  end(`exited (${signal ?? `code ${code}`})`);
1002
1020
  });
1003
1021
  this.serve = child;
@@ -1260,7 +1278,7 @@ export class TeamsMachine extends MachineCore {
1260
1278
 
1261
1279
  #noticeTarget(address){
1262
1280
  return sessionNoticeTarget(address,{machine:machineName(this.env),
1263
- // liveness is the live-session map's, read in-process (no process, no remote-deliver)
1281
+ // liveness is the live-session map's, read in-process (no process)
1264
1282
  mail:async()=>({code:0,sessions:this.live.sessions()}),
1265
1283
  discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
1266
1284
  recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
@@ -1274,9 +1292,10 @@ export class TeamsMachine extends MachineCore {
1274
1292
  if(status.state==='running')return;
1275
1293
  if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
1276
1294
  const code=await new Promise((resolve,reject)=>{
1277
- // Explicit native approval and sandbox flags suppress open's unattended defaults.
1278
- const child=spawn(this.supercodeBin,['open',address,'--detach','--',...status.resume.args],
1279
- {cwd:status.resume.cwd,env:{...this.env,SUPERCODE_POPUPS:'0'},stdio:'ignore'});
1295
+ // Explicit native approval and sandbox flags suppress open's unattended defaults. A resume made for a notice opens
1296
+ // no window on the machine's screen: `--window=false` is the launch's own choice, which the daemon honours.
1297
+ const child=spawn(this.supercodeBin,['open',address,'--detach','--window=false','--',...status.resume.args],
1298
+ {cwd:status.resume.cwd,env:this.env,stdio:'ignore'});
1280
1299
  const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
1281
1300
  child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
1282
1301
  });
@@ -1358,7 +1377,7 @@ export class TeamsMachine extends MachineCore {
1358
1377
  })();
1359
1378
  }
1360
1379
 
1361
- /** A send through the mail door, for a session here (message.rs `remote_deliver` send): one for another machine is
1380
+ /** A send through the mail door, for a session here: one for another machine is
1362
1381
  * refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
1363
1382
  * daemon sends on through the one way out. */
1364
1383
  async #sendHere(request, link) {
@@ -1371,7 +1390,7 @@ export class TeamsMachine extends MachineCore {
1371
1390
  }, { link });
1372
1391
  }
1373
1392
 
1374
- /** A message another machine's mailbox carried here, filed in a mailbox here (message.rs `remote_deliver` file): the
1393
+ /** A message another machine's mailbox carried here, filed in a mailbox here: the
1375
1394
  * sender is resolved here from the link it came through, never from the filer's own claim; a wake it carries is
1376
1395
  * requested once, for a message new here. */
1377
1396
  #fileHere(request, link) {
@@ -1502,8 +1521,9 @@ export class TeamsMachine extends MachineCore {
1502
1521
  return refuse(`no interact grant for ${target}`);
1503
1522
  }
1504
1523
  accept();
1505
- // The user-turn door every supercode has (harness.v1 sessions.message, as_user): the session's runtime input,
1506
- // its pane's empty composer, or its mailbox until the composer is free. The sender is named after the member.
1524
+ // The user-turn door every supercode has (sessions.message as_user, this daemon's own: mail/send.mjs): the session's
1525
+ // runtime input, its pane's empty composer, or its mailbox until the composer is free. The sender is named after the
1526
+ // member.
1507
1527
  const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
1508
1528
  // A keyed line is typed once: the server retries a line whose open outlived its bound, and the attempt it gave up
1509
1529
  // on may still be accepted here. A repeat of a key answers what its first attempt answers.
@@ -1526,12 +1546,12 @@ export class TeamsMachine extends MachineCore {
1526
1546
  : request.text;
1527
1547
  const note = { harness, session, text, at: Date.now(), source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } };
1528
1548
  try { this.onTurn?.(note); } catch { /* naming is best-effort; the turn goes either way */ }
1529
- this.#serveRequest('harness.v1.sessions.message', {
1530
- locator: { harness, session_id: session, storage: { kind: 'file', path: '' } },
1531
- text,
1532
- from_name: entry.name ?? principal ?? 'a Room member',
1533
- as_user: true,
1534
- }, TURN_CONFIRM_MS).then((result) => {
1549
+ const confirmed = new Promise((resolve, reject) => {
1550
+ const timer = setTimeout(() => reject(new Error(`no answer in ${TURN_CONFIRM_MS} ms`)), TURN_CONFIRM_MS);
1551
+ this.mailSend.message({ locator: { harness, session_id: session }, text, from_name: entry.name ?? principal ?? 'a Room member', as_user: true })
1552
+ .then(resolve, reject).finally(() => clearTimeout(timer));
1553
+ });
1554
+ confirmed.then((result) => {
1535
1555
  const delivered = result?.message_id && !result.refusal;
1536
1556
  this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
1537
1557
  if (!delivered) try { this.onTurnWithdrawn?.(note); } catch { /* nothing to withdraw */ }
@@ -1707,8 +1727,7 @@ export class TeamsMachine extends MachineCore {
1707
1727
  return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
1708
1728
  }
1709
1729
 
1710
- /** A filing's sender as this machine knows it, with no process and no request (the rule remote-deliver keeps,
1711
- * mailbox.rs sender_identity): this filing door is local, so a sender from this machine's address is a session here
1730
+ /** A filing's sender as this machine knows it, with no process and no request (mailbox.rs `sender_identity`'s rule): this filing door is local, so a sender from this machine's address is a session here
1712
1731
  * (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
1713
1732
  * reaches this one only through its link, so a return address is never an identity. */
1714
1733
  #observeSender(from) {
@@ -2122,6 +2141,41 @@ export class TeamsMachine extends MachineCore {
2122
2141
 
2123
2142
  /** The program a pane runs, as the terminal package names kinds: what it was
2124
2143
  * launched as, else what its foreground command is. */
2144
+ /**
2145
+ * A user's own turn typed into `pane` (its `session`) as its own submitted turn, only into an empty composer (read twice,
2146
+ * so a person who just started typing is not typed over): `panes.turn`, and the daemon's own user turns (mail/send.mjs
2147
+ * `userTurn`). Answers `#deliverPaneText`'s result; `{ refused }` for a pane running another harness than `harness`
2148
+ * names; `{ waits }` for a draft or a screen with no composer (the caller keeps the turn and asks again).
2149
+ */
2150
+ async #paneTurn(session, pane, text, { messageIds, harness } = {}) {
2151
+ if (this.suspended.has(pane)) throw Object.assign(new Error(`pane ${pane} is suspended: remote input is refused until resume`), { code: 423 });
2152
+ const kind = this.#paneKind(pane, session);
2153
+ // Mail is typed only into its own harness's pane. A process inherits SUPERCODE_TEAMS_PANE from whatever ran it,
2154
+ // so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
2155
+ // was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
2156
+ // pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
2157
+ // Claude install shows as its version.
2158
+ const addressed = harness == null ? null : String(harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
2159
+ if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
2160
+ return { refused: { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` } };
2161
+ }
2162
+ if (kind === 'claude-code') {
2163
+ // A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
2164
+ // still holds refuses the turn at once and the session waits again, for the next turn to try.
2165
+ const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
2166
+ if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
2167
+ await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
2168
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
2169
+ }
2170
+ const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
2171
+ const first = await draftOf();
2172
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
2173
+ const second = await draftOf();
2174
+ if (first !== false || second !== false) return { waits: { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' } };
2175
+ }
2176
+ return this.#deliverPaneText(session, pane, text, { deadlineMs: Date.now() + 10_000, messageIds });
2177
+ }
2178
+
2125
2179
  #paneKind(contextKey, session) {
2126
2180
  const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
2127
2181
  const named = String(launched ?? '').trim().toLowerCase();
@@ -3119,34 +3173,9 @@ export class TeamsMachine extends MachineCore {
3119
3173
  if (sessionTurn?.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(sessionTurn.refused)); return sessionTurn.refused; }
3120
3174
  const session = await this.#paneByContext(params.pane);
3121
3175
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
3122
- if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
3123
- const kind = this.#paneKind(params.pane, session);
3124
- // Mail is typed only into its own harness's pane. A process inherits SUPERCODE_TEAMS_PANE from whatever ran it,
3125
- // so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
3126
- // was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
3127
- // pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
3128
- // Claude install shows as its version.
3129
- const addressed = params.harness == null ? null : String(params.harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
3130
- if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
3131
- const refused = { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` };
3132
- act('control', `pane:${params.pane}`, 'refused', JSON.stringify(refused));
3133
- return refused;
3134
- }
3135
- if (kind === 'claude-code') {
3136
- // A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
3137
- // still holds refuses the turn at once and the session waits again, for the next turn to try.
3138
- const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
3139
- if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
3140
- await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
3141
- await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
3142
- }
3143
- const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
3144
- const first = await draftOf();
3145
- await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
3146
- const second = await draftOf();
3147
- if (first !== false || second !== false) return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
3148
- }
3149
- let result = await this.#deliverPaneText(session, params.pane, params.text, { deadlineMs: Date.now() + 10_000, messageIds: params.message_ids });
3176
+ let result = await this.#paneTurn(session, params.pane, params.text, { messageIds: params.message_ids, harness: params.harness });
3177
+ if (result.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(result.refused)); return result.refused; }
3178
+ if (result.waits) return result.waits;
3150
3179
  if ((result.delivered || result.queued) && !params.message_ids?.length && !turnBy) { const unrecorded = await this.#recordInput(entry, params.pane, session, params.text); if (unrecorded) result = { ...result, unrecorded }; }
3151
3180
  act('control', `pane:${params.pane}`, result.delivered ? 'input' : result.queued ? 'input-pending' : 'failed', JSON.stringify(result));
3152
3181
  return result;
@@ -3318,7 +3347,8 @@ export class TeamsMachine extends MachineCore {
3318
3347
  // 3a): this machine's own processes only, answered here.
3319
3348
  case 'harness.v1.mail.list': case 'harness.v1.mail.show': case 'harness.v1.mail.inbox': case 'harness.v1.mail.acknowledge': {
3320
3349
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
3321
- if (method === 'harness.v1.mail.list') return { rows: await this.mail.list() };
3350
+ // `local`: this machine's sessions only (`message mcp`'s list_agents, as the native one listed them)
3351
+ if (method === 'harness.v1.mail.list') return { rows: params.local === true ? await this.mail.localRows() : await this.mail.list() };
3322
3352
  if (method === 'harness.v1.mail.show') {
3323
3353
  const ids = (Array.isArray(params.ids) ? params.ids : []).filter((id) => typeof id === 'string');
3324
3354
  return params.replies ? this.mail.showReplies(ids) : await this.mail.show(ids);
@@ -3350,7 +3380,9 @@ export class TeamsMachine extends MachineCore {
3350
3380
  // acknowledged once it has printed it (harness.v1.mail.acknowledge)
3351
3381
  const caller = await this.#callerOf(entry, params);
3352
3382
  if (typeof caller !== 'string' || /^sc:[^:]+:board:/.test(caller)) return { code: 2, text: "Can't tell which session is running this command, so replies would have nowhere to go. Nothing was sent. Run it from your agent session's own shell tool." };
3353
- const read = await this.mail.inbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid });
3383
+ // `via`: the door the claims record (`inbox`, or `read_messages` for `message mcp`'s tool)
3384
+ const via = params.via === 'read_messages' ? 'read_messages' : 'inbox';
3385
+ const read = await this.mail.inbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid, via });
3354
3386
  let token = null;
3355
3387
  if (read.claimed.length) {
3356
3388
  token = randomBytes(12).toString('hex');
@@ -3360,7 +3392,8 @@ export class TeamsMachine extends MachineCore {
3360
3392
  for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
3361
3393
  this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
3362
3394
  }
3363
- return { code: 0, address: caller, rendered: read.rendered, rows: read.rows, token };
3395
+ // `plain`: each message as its reader is shown it (the envelope rendered), with no listing's line about when
3396
+ return { code: 0, address: caller, rendered: read.rendered, plain: read.stored.map((item) => render(item.envelope)), rows: read.rows, token };
3364
3397
  }
3365
3398
  // Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
3366
3399
  // there as its caller, resolved here, where the map that holds it is. Local only; never the app door's.
package/mail/agents.mjs CHANGED
@@ -12,6 +12,8 @@ import { cameThroughLink, parseAddress, threadOf } from './compose.mjs';
12
12
 
13
13
  /** The pseudo-harness of an agent address. */
14
14
  export const AGENT_HARNESS = 'agent';
15
+ /** How recent a folder time is not trusted to say its folder is unchanged (a coarse clock's tick, a 1 s filesystem). */
16
+ const RACY_MS = 2000;
15
17
  /** How long a send waits for a resumed session to be reachable again. */
16
18
  export const RESUME_WAIT_MS = 45_000;
17
19
 
@@ -63,34 +65,64 @@ export class Agents {
63
65
  thread(id) { return validName(id) ? readJson(join(this.threadsDir, `${id}.json`)) : null; }
64
66
 
65
67
  /** Every thread, oldest first. */
66
- threads() {
67
- // Read once and kept while the folder is unchanged: every writer (this daemon, and mail_agent.rs's in the native
68
- // watch until step 4) renames a whole record into it, which moves the folder's time. One stat a call, not a read of
69
- // every record: a send's plan asks this on every send, and an agent's threads grow with its history.
68
+ threads() { return this.#records().list.map((thread) => structuredClone(thread)); }
69
+
70
+ /**
71
+ * The thread records, read once and kept while their folder is unchanged, with an index of them by holder and by
72
+ * channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
73
+ * record into the folder, which moves its time: one stat a call, never a read of every record, though a send's plan
74
+ * asks on every send and an agent's threads grow with its history. A folder time within RACY_MS of now is not trusted
75
+ * (a write in the same tick of a coarse clock leaves it unmoved): the folder is listed again, each record checked by
76
+ * its inode, until its time is older.
77
+ */
78
+ #records() {
70
79
  let changed;
71
- try { changed = statSync(this.threadsDir).mtimeMs; } catch { return []; }
72
- if (this.threadsCache?.changed === changed) return this.threadsCache.list.map((thread) => structuredClone(thread));
80
+ try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
81
+ const cache = this.threadsCache;
82
+ if (cache?.changed === changed && Date.now() - changed > RACY_MS) return cache;
83
+ // The folder changed, or changed too recently for its time to be trusted: its names are listed and each record's
84
+ // file is stat'ed, and only a record whose inode moved (every writer renames a whole new file into place) is read
85
+ // again. A burst of writes costs a listing and a stat a record, not a read of every record.
73
86
  let names = [];
74
- try { names = readdirSync(this.threadsDir); } catch { return []; }
75
- const list = names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.threadsDir, name))).filter((thread) => thread?.id)
87
+ try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
88
+ const files = new Map();
89
+ for (const name of names) {
90
+ if (!name.endsWith('.json')) continue;
91
+ // its inode, time and size together (an inode can be used again once freed): a rename moves at least one
92
+ let stamp;
93
+ try { const stat = statSync(join(this.threadsDir, name)); stamp = `${stat.ino}:${stat.mtimeMs}:${stat.size}`; } catch { continue; }
94
+ const known = cache?.files?.get(name);
95
+ const thread = known && known.stamp === stamp ? known.thread : readJson(join(this.threadsDir, name));
96
+ if (thread?.id) files.set(name, { stamp, thread });
97
+ }
98
+ const list = [...files.values()].map((file) => file.thread)
76
99
  .sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
77
- this.threadsCache = { changed, list };
78
- return list.map((thread) => structuredClone(thread));
100
+ const byHolder = new Map(), byMarker = new Map();
101
+ for (const thread of list) {
102
+ byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
103
+ for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
104
+ }
105
+ const records = { changed, list, byHolder, byMarker, files };
106
+ // kept either way (its records are each current by inode); trusted without a listing only once its time is old
107
+ this.threadsCache = records;
108
+ return records;
79
109
  }
80
110
 
111
+
81
112
  /** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
82
113
  heldBy(address) {
83
- return this.threads().reverse().find((thread) => {
84
- if (thread.holder !== address || thread.parent != null) return false;
114
+ const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
115
+ if (thread.parent != null) return false;
85
116
  const agent = this.load(thread.agent);
86
117
  return agent != null && agent.main_session !== address;
87
- }) ?? null;
118
+ });
119
+ return held ? structuredClone(held) : null;
88
120
  }
89
121
 
90
122
  /** The thread whose channel message has the platform id `marker`, with that message's id. */
91
123
  ofMarker(marker) {
92
- for (const thread of this.threads()) if (thread.markers?.[marker]) return { thread, id: thread.markers[marker] };
93
- return null;
124
+ const found = this.#records().byMarker.get(marker);
125
+ return found ? { thread: structuredClone(found.thread), id: found.id } : null;
94
126
  }
95
127
 
96
128
  /** The agent `address` is a session of: its main, or a thread's holder. */
@@ -163,7 +195,7 @@ export class Agents {
163
195
  }
164
196
 
165
197
  /** The threads of the agent `name`, oldest first. */
166
- threadsOf(name) { return this.threads().filter((thread) => thread.agent === name); }
198
+ threadsOf(name) { return this.#records().list.filter((thread) => thread.agent === name).map((thread) => structuredClone(thread)); }
167
199
 
168
200
  /**
169
201
  * A line that reached an agent's main session through its own input (its terminal, its DM) is a root of the agent:
@@ -310,8 +342,8 @@ export class Agents {
310
342
  return (await this.#resumeArguments(address)) != null;
311
343
  }
312
344
 
313
- /** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach -- <its recorded
314
- * mode>`: never open's unattended defaults (mail_agent.rs `resume`). */
345
+ /** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach --window=false --
346
+ * <its recorded mode>`: never open's unattended defaults (mail_agent.rs `resume`), and no window on the screen. */
315
347
  async resume(address) {
316
348
  const recorded = await this.#resumeArguments(address);
317
349
  if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
@@ -324,7 +356,8 @@ export class Agents {
324
356
  }
325
357
  writeAtomically(join(this.agentsDir, 'resumed', blake3Hex(address).slice(0, 24)), String(Date.now()));
326
358
  const { code, stderr } = await new Promise((resolve) => {
327
- const child = spawn(this.supercodeBin, ['open', parsed.session, '--detach', '--', ...recorded], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
359
+ // a holder resumed by mail runs in the background: no window opens on the machine's screen for it
360
+ const child = spawn(this.supercodeBin, ['open', parsed.session, '--detach', '--window=false', '--', ...recorded], { stdio: ['ignore', 'ignore', 'pipe'], env: this.env });
328
361
  let err = '';
329
362
  child.stderr.on('data', (chunk) => { err += chunk; });
330
363
  child.on('error', (error) => resolve({ code: 1, stderr: error.message }));
package/mail/reads.mjs CHANGED
@@ -116,8 +116,8 @@ function pendingFromTranscript(path) {
116
116
  export class MailReads {
117
117
  /** `records()` answers `{ records, unread, self }`: the records this daemon holds (machine-records.mjs `read`), why
118
118
  * it holds none when it follows no machine, and this machine's name. */
119
- constructor({ machine, live, store, records, descriptors, homes, paneQuestion = async () => null, askMachine }) {
120
- Object.assign(this, { machine, live, store, records, descriptors, homes, paneQuestion, askMachine });
119
+ constructor({ machine, live, store, records, descriptors, homes, paneQuestion = async () => null, askMachine, log = () => {} }) {
120
+ Object.assign(this, { machine, live, store, records, descriptors, homes, paneQuestion, askMachine, log });
121
121
  }
122
122
 
123
123
  // ---- list ------------------------------------------------------------------------------------------------------
@@ -248,9 +248,12 @@ export class MailReads {
248
248
  * `{ rows, code }`.
249
249
  */
250
250
  async show(ids) {
251
+ const began = performance.now();
251
252
  const rows = await this.showRows(ids);
253
+ const here = performance.now();
252
254
  const wanted = rows.filter((row) => row.found === false && !row.ambiguous).map((row) => row.id);
253
255
  const unsearched = [];
256
+ const timing = {};
254
257
  if (wanted.length) {
255
258
  const held = heldRecords(this.records());
256
259
  const holds = (record, id) => Object.values(record?.mail ?? {}).some((box) => ['unread', 'recent'].some((part) => (Array.isArray(box?.[part]) ? box[part] : []).some((header) => typeof header?.id === 'string' && (header.id === id || header.id.startsWith(id)))));
@@ -267,6 +270,7 @@ export class MailReads {
267
270
  if (short.length) shorts.push([entry.machine, short]);
268
271
  }
269
272
  if (wanted.some((id) => id.length < FULL_ID_LEN || !placed.has(id))) for (const [machine, short] of shorts) unsearched.push([machine, `its mail window may be incomplete: ${short.join('; ')}`]);
273
+ timing.records = performance.now();
270
274
  const answers = await Promise.all([...asks].map(async ([machine, asked]) => {
271
275
  try { return [machine, await this.askMachine(machine, { op: 'show', ids: asked })]; } catch (error) { return [machine, error]; }
272
276
  }));
@@ -285,6 +289,9 @@ export class MailReads {
285
289
  unsearched.push(['*', `${older.join(', ')} ${older.length === 1 ? 'is' : 'are'} in no pushed mail window (each machine's record holds its unread mail and the last ${RECENT_PER_MAILBOX} messages of each mailbox): an older message is shown by \`supercode message show\` on the machine that holds it${followed.length ? ` (${followed.join(', ')})` : ''}`]);
286
290
  }
287
291
  }
292
+ // a slow show says where its time went (this machine's mailboxes, the held records, the machines it asked)
293
+ const total = performance.now() - began;
294
+ if (total > 1000) this.log(`show of ${ids.join(' ')} took ${Math.round(total)} ms: here ${Math.round(here - began)} ms${timing.records ? `, held records ${Math.round(timing.records - here)} ms, other machines ${Math.round(performance.now() - timing.records)} ms` : ''}`);
288
295
  const missing = rows.some((row) => row.found !== true);
289
296
  const notSearched = unsearched.map(([machine, why]) => (machine === '*' ? why : `${machine}: ${why}`));
290
297
  if (notSearched.length) for (const row of rows) if (row.found === false && !row.ambiguous) { row.found = null; row.unsearched = notSearched; }
@@ -314,8 +321,8 @@ export class MailReads {
314
321
  * mail with when each reached the model, from its transcript; the newest `last` of the read only (`inbox`). Answers
315
322
  * `{ stored, deliveries, claimed }`; the caller acknowledges each claimed one once it is printed (`acknowledge`).
316
323
  */
317
- async inbox(address, { all = false, last = null, pid = process.pid } = {}) {
318
- const claimed = this.store.claimUnread(address, 'inbox', address, pid).map((stored) => this.#whole(stored) ?? stored);
324
+ async inbox(address, { all = false, last = null, pid = process.pid, via = 'inbox' } = {}) {
325
+ const claimed = this.store.claimUnread(address, via, address, pid).map((stored) => this.#whole(stored) ?? stored);
319
326
  const transcript = all ? await readTranscript(address, this.homes) : null;
320
327
  let stored;
321
328
  if (all) {
package/mail/relay.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // doors, run by the relay's Claude (docs/architecture/overview.md, "Pinned native doors"). Its folder is the native
8
8
  // sender's own (`<mail>/relays/<blake3(name)[..24]>/`, its record, settings and send log), so the native `message
9
9
  // watch`, which retires relays until step 4, reads what the daemon writes.
10
- import { mkdirSync, readFileSync, rmSync, utimesSync, watch, writeFileSync } from 'node:fs';
10
+ import { mkdirSync, readFileSync, rmSync, watch, writeFileSync } from 'node:fs';
11
11
  import { connect } from 'node:net';
12
12
  import { join } from 'node:path';
13
13
  import { blake3Hex } from './envelope.mjs';
@@ -18,8 +18,13 @@ 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
+ /** A relay send slower than this says where its time went. */
22
+ const SLOW_SEND_MS = 2000;
21
23
  /** Largest message relayed into a Claude session: the relay copies it into a model turn byte for byte. */
22
24
  export const MAX_RELAYED_BYTES = 100_000;
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
+ * machine): beyond them a relay's runtime is closed (its folder kept: it starts again when next needed). */
27
+ export const RELAYS_RUNNING = 4;
23
28
 
24
29
  const alive = (pid) => { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
25
30
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
@@ -56,9 +61,23 @@ export class Relays {
56
61
  * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
57
62
  * `runtimeNamed(receiptId)` one receipt read by its own name.
58
63
  */
59
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null }) {
60
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed });
64
+ constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, log = () => {} }) {
65
+ Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, log });
61
66
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
67
+ this.running = new Map(); // relay folder → { connection, runtime_id, usedAt }: the relays this daemon runs
68
+ }
69
+
70
+ /** The relays beyond the RELAYS_RUNNING most recently used have their runtimes closed, none carrying a send: this
71
+ * daemon starts every relay and holds every relay's send lock, so it alone closes one (a closer that did not hold the
72
+ * lock closed relays mid-send, and every send after paid a relay's start). */
73
+ #cap() {
74
+ const kept = [...this.running].sort(([, a], [, b]) => b.usedAt - a.usedAt);
75
+ for (const [directory, relay] of kept.slice(RELAYS_RUNNING)) {
76
+ if (this.sending.has(directory)) continue;
77
+ this.running.delete(directory);
78
+ void this.serve('harness.v1.runtimes.close', { connection: relay.connection, runtime_id: relay.runtime_id }, 15_000)
79
+ .catch((error) => this.log(`relay ${directory.split(/[\\/]/).pop()} beyond the ${RELAYS_RUNNING} most recent was not closed: ${error.message}`));
80
+ }
62
81
  }
63
82
 
64
83
  /** The relay that speaks for `represented`, known as `representedName`: its name and its files. */
@@ -187,21 +206,24 @@ export class Relays {
187
206
  this.sending.set(spec.directory, turn);
188
207
  await previous;
189
208
  try {
190
- // its record is the relay's last use: the native sweep that keeps the most recently used relays running reads its
191
- // time, and a relay carrying a send is the most recent
192
- try { const now = new Date(); utimesSync(spec.record, now, now); } catch { /* none yet */ }
193
209
  const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
210
+ const steps = [], began = performance.now();
211
+ let mark = began;
212
+ const step = (name) => { const now = performance.now(); steps.push(`${name} ${Math.round(now - mark)} ms`); mark = now; };
213
+ step('lock');
194
214
  let { runtime, connection } = await this.#ensure(spec);
215
+ step('runtime (started or reused)');
216
+ this.#used(spec, connection);
195
217
  rmSync(spec.receipt, { force: true });
196
218
  writeFileSync(spec.queue, JSON.stringify(queued));
197
219
  let receipt;
198
220
  for (let attempt = 0; ; attempt++) {
199
- try { await this.serve('harness.v1.runtimes.send_input', { connection, text: sendTurn(queued) }, 30_000); receipt = await this.#receipt(spec, this.#pid(spec, runtime)); }
221
+ 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'); }
200
222
  catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
201
223
  const pid = this.#pid(spec, runtime);
202
224
  // only a relay proven dead is started again; one alive but slow may still deliver
203
225
  if (receipt.delivered || attempt === 1 || pid == null || alive(pid)) break;
204
- try { ({ runtime, connection } = await this.#ensure(spec)); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
226
+ try { ({ runtime, connection } = await this.#ensure(spec)); this.#used(spec, connection); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
205
227
  rmSync(spec.receipt, { force: true });
206
228
  }
207
229
  rmSync(spec.queue, { force: true });
@@ -210,6 +232,8 @@ export class Relays {
210
232
  sent[receiver.sessionId] = messageId;
211
233
  writeFileSync(spec.sent, JSON.stringify(sent));
212
234
  }
235
+ // a slow relay send says where its time went, in the daemon's log (what a person reads of the connector)
236
+ if (performance.now() - began > SLOW_SEND_MS) this.log(`relay send to ${receiver.name || receiver.sessionId} took ${Math.round(performance.now() - began)} ms: ${steps.join(', ')}`);
213
237
  return receipt;
214
238
  } catch (error) {
215
239
  rmSync(spec.queue, { force: true });
@@ -217,8 +241,15 @@ export class Relays {
217
241
  } finally {
218
242
  release();
219
243
  if (this.sending.get(spec.directory) === turn) this.sending.delete(spec.directory);
244
+ this.#cap();
220
245
  }
221
246
  }
247
+
248
+ /** A relay this daemon runs, used now. */
249
+ #used(spec, connection) {
250
+ const runtimeId = readJson(spec.record)?.runtime_id;
251
+ if (typeof runtimeId === 'string') this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
252
+ }
222
253
  }
223
254
 
224
255
  /** Whether a local port answers a connection within 300 ms. */
package/mail/send.mjs CHANGED
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { blake3Hex, render, shortId } from './envelope.mjs';
10
10
  import { AGENT_HARNESS, RESUME_WAIT_MS } from './agents.mjs';
11
11
  import { MAX_RELAYED_BYTES, RELAY_NAME_PREFIX } from './relay.mjs';
12
- import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
12
+ import { FULL_ID_LEN, cameThroughLink, envelope as makeEnvelope, makeAddress, messageIdFor, newMessageId, normalMachineName, parseAddress, senderIdentity, subjectLine, threadOf } from './compose.mjs';
13
13
 
14
14
  export const EXIT_REFUSED = 2;
15
15
  export const EXIT_UNKNOWN = 3;
@@ -32,6 +32,8 @@ const NAME_SEEN_EVERY_MS = 60 * 60 * 1000;
32
32
 
33
33
  const outcome = (code, text, receipt = null) => ({ code, text, receipt });
34
34
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
35
+ /** The daemon pane a live session runs in, or null (mail_route.rs `daemon_pane`). */
36
+ const paneOf = (session) => { const pane = typeof session.pane === 'string' ? session.pane.split(':')[0] : null; return pane && /^p_[A-Za-z0-9_-]+$/.test(pane) ? pane : null; };
35
37
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
36
38
 
37
39
  /** A delivery a door refused before anything was sent. */
@@ -42,10 +44,11 @@ export class MailSend {
42
44
  * `live` the live-session map; `store` the mail store; `agents` an agent's records and plan; `relays` the Claude relay
43
45
  * sender; `askMachine(machine, request)` one request to another machine's mail door; `deliverRuntime(receipt, text,
44
46
  * wake)` one hosted runtime's input ('steered', 'started' or 'not_woken'); `codexAnswer(request)` a native Codex
45
- * question's answer through its app-server.
47
+ * question's answer through its app-server; `typeTurn(pane, harness, text, ids)` a user's own turn typed into a daemon
48
+ * pane's empty composer (machine.mjs `#paneTurn`: `{ delivered }`, `{ queued }`, or neither when it waits).
46
49
  */
47
- constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, log = () => {} }) {
48
- Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, log });
50
+ constructor({ machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log = () => {} }) {
51
+ Object.assign(this, { machine, mailRoot, live, store, agents, relays, askMachine, deliverRuntime, codexAnswer, typeTurn, log });
49
52
  }
50
53
 
51
54
  // ---- one send ----------------------------------------------------------------------------------------------------
@@ -424,9 +427,10 @@ export class MailSend {
424
427
  // ---- harness serve's sessions.message ----------------------------------------------------------------------------
425
428
 
426
429
  /**
427
- * `harness.v1.sessions.message` without `as_user` (harness_service.rs `message_live_session`): `text` from an operator
428
- * mailbox (`from_name`, `supercode` by default) into the session or agent `locator` names. A refusal is a result, not
429
- * an error: `{ delivered_to_bus: false, refusal: { reason, message } }`. harness serve adds its own inbound controls.
430
+ * `harness.v1.sessions.message` (harness_service.rs `message_live_session`): `text` from an operator mailbox
431
+ * (`from_name`, `supercode` by default) into the session or agent `locator` names; with `as_user`, as that session's
432
+ * own user turn. A refusal is a result, not an error: `{ delivered_to_bus: false, refusal: { reason, message } }`.
433
+ * harness serve adds its own inbound controls.
430
434
  */
431
435
  async message(params) {
432
436
  const refused = (reason, message) => ({ delivered_to_bus: false, refusal: { reason, message } });
@@ -448,6 +452,8 @@ export class MailSend {
448
452
  const short = target.name.split('@')[0];
449
453
  if (sessions.filter((row) => row.door === 'native' && row.name.split('@')[0] === short).length !== 1) return refused('identity_mismatch', `the registry name \`${short}\` no longer resolves to session \`${sessionId}\` alone; refusing rather than delivering into another session`);
450
454
  }
455
+ // the session's own user speaking (a Room member's line, a voice bridge, a board the owner types in)
456
+ if (params.as_user === true) return this.#userTurn(params, sender, fromName, receiver, text);
451
457
  const door = harness === AGENT_HARNESS ? null : this.#door(receiver, sessions);
452
458
  const subject = subjectLine(params.subject ?? null);
453
459
  let id = null;
@@ -488,6 +494,127 @@ export class MailSend {
488
494
  };
489
495
  }
490
496
 
497
+ // ---- the user's own turn ------------------------------------------------------------------------------------------
498
+
499
+ /**
500
+ * `sessions.message` with `as_user`: user mail, through the one door that carries
501
+ * the user's authority (a hosted runtime's input, or the session's pane when its composer is empty; otherwise it waits
502
+ * in the mailbox). A line to an agent is typed into the session its plan wakes (main for a root, a thread's holder for
503
+ * a reply in it), its other participants filed a record of it unread.
504
+ */
505
+ async #userTurn(params, sender, fromName, receiver, text) {
506
+ const refused = (reason, message) => ({ delivered_to_bus: false, refusal: { reason, message } });
507
+ const sent = makeEnvelope({ id: newMessageId(), from: sender, from_name: `${fromName}@${this.machine}`, sender_identity: senderIdentity(sender, null, this.machine), kind: 'user', reply_via: { mode: 'none' }, body: text });
508
+ const target = { harness: params.locator.harness, session_id: params.locator.session_id };
509
+ let to = receiver;
510
+ const agentAddress = parseAddress(receiver).harness === AGENT_HARNESS ? receiver : null;
511
+ if (agentAddress) {
512
+ // The owner writing to an agent types into its main session (docs/adr/0008-agent-mailbox.md, decisions 3 and 12): a
513
+ // typed line there is the owner's, and main answers or delegates it.
514
+ const agent = this.agents.load(parseAddress(receiver).session);
515
+ if (!agent) return refused('delivery_failed', `no agent named ${parseAddress(receiver).session} is declared on this machine`);
516
+ to = agent.main_session;
517
+ // A line to an agent is a root (or a reply in its thread): the agent's mailbox files its thread, with the owner a
518
+ // participant, before it is typed
519
+ const inReplyTo = typeof params.in_reply_to === 'string' ? params.in_reply_to : null;
520
+ sent.in_reply_to = inReplyTo;
521
+ sent.thread = inReplyTo ? (this.#filedThread(inReplyTo) ?? inReplyTo) : null;
522
+ let plan;
523
+ try { plan = await this.agents.plan(sent, agentAddress, {}); } catch (error) { return refused('delivery_failed', error.message); }
524
+ if (plan) {
525
+ const typedTo = plan.recipients.find((item) => item.wake && parseAddress(item.address)?.harness !== 'operator')?.address;
526
+ if (typedTo) to = typedTo;
527
+ // the agent's own mailbox keeps the record of what was addressed to it
528
+ if (plan.agent) { try { this.store.deliverRead(plan.agent, sent); } catch { /* the record is best kept */ } }
529
+ // a copy of the owner's line is a record of a line typed elsewhere (as decision 12's copies are), never a turn
530
+ // of the copy's session: pending user mail would be typed into it
531
+ const copy = { ...sent, kind: 'typed' };
532
+ for (const other of plan.recipients.filter((item) => item.address !== to && item.address !== sender)) {
533
+ try { this.#fileUnread(other.address, copy); } catch { /* a copy is best kept */ }
534
+ }
535
+ // every owner line to any agent is the owner's account manager's as CC (decision 17), unless it is that agent's own
536
+ const accountManager = this.agents.ownersAccountManager();
537
+ if (accountManager && accountManager.name !== parseAddress(agentAddress).session) {
538
+ const main = accountManager.main_session;
539
+ if (main !== to && !plan.recipients.some((item) => item.address === main)) { try { this.#fileUnread(main, copy); } catch { /* best kept */ } }
540
+ }
541
+ }
542
+ // A thread's holder the mailbox launched that has since closed (decision 9) is resumed the way it was launched, and
543
+ // takes the line once its door is back (decision 5); the line is never a new root.
544
+ if (!this.live.sessions().some((row) => row.address === to) && await this.agents.resumable(to)) {
545
+ try { await this.agents.resume(to); } catch (error) { return refused('no_user_door', `${to} was not resumed: ${error.message}`); }
546
+ for (const started = Date.now(); !this.live.sessions().some((row) => row.address === to) && Date.now() - started < RESUME_WAIT_MS;) await sleep(1000);
547
+ }
548
+ }
549
+ let turn;
550
+ try { turn = await this.#deliverUserTurn(sent, to); }
551
+ catch (error) {
552
+ // `no_user_door` says only that the session has no door for its user's turn (a caller may then reach it as a
553
+ // peer); a door that failed is `delivery_failed`, so the line is never sent twice
554
+ return refused(this.#hasUserDoor(to) ? 'delivery_failed' : 'no_user_door', error.message);
555
+ }
556
+ return { delivered_to_bus: turn !== 'waiting', message_id: sent.id, target, delivery: { door: turn === 'steered' || turn === 'started' ? 'runtime' : 'pane', how: turn } };
557
+ }
558
+
559
+ /** Whether `to` runs here with a door that carries its user's own turn: a hosted runtime's input or a daemon pane. */
560
+ #hasUserDoor(to) {
561
+ const session = this.live.sessions().find((row) => row.address === to);
562
+ return Boolean(session && (session.door === 'runtime' || paneOf(session)));
563
+ }
564
+
565
+ /**
566
+ * The user's own turn into `to`: a hosted runtime's input ('steered' or 'started'),
567
+ * or filed in its mailbox and typed into its pane once the composer is empty ('typed', or 'waiting' until then). A
568
+ * session with neither (running outside supercode) is refused, never reached as a peer instead.
569
+ */
570
+ async #deliverUserTurn(sent, to) {
571
+ const session = this.live.sessions().find((row) => row.address === to);
572
+ if (!session) throw new Error(`${to} is not running; nothing was sent`);
573
+ this.store.open(to);
574
+ if (session.door === 'runtime') {
575
+ const parsed = parseAddress(to);
576
+ const receipt = this.live.runtimeReceipt(parsed.harness, parsed.session);
577
+ if (!receipt) throw new Error('the live runtime\'s receipt is gone');
578
+ const how = await this.deliverRuntime(receipt, sent.body, true);
579
+ this.store.deliverRead(to, sent);
580
+ return how === 'steered' ? 'steered' : 'started';
581
+ }
582
+ const pane = paneOf(session);
583
+ if (!pane) {
584
+ const name = session.name.split('@')[0];
585
+ throw new Error(`${name} runs outside supercode, where nothing can speak as its user. Open it in a pane with \`supercode open ${name}\`; nothing was sent.`);
586
+ }
587
+ this.store.deliver(to, sent);
588
+ const typed = await this.typeUserTurns(to, pane);
589
+ return typed.includes(sent.id) ? 'typed' : 'waiting';
590
+ }
591
+
592
+ /**
593
+ * The user's waiting turns of `address` typed into `pane`, oldest first, each only when its composer is empty; stops
594
+ * at the first that has to wait. Each is claimed before it is typed: the native watch types waiting turns too (until
595
+ * step 4), and two typers filling one composer sent a turn's text twice in one prompt. Answers the ids typed
596
+ * (mail_route.rs `type_user_turns`).
597
+ */
598
+ async typeUserTurns(address, pane) {
599
+ const typed = [];
600
+ const harness = parseAddress(address).harness;
601
+ for (const waiting of this.store.userTurns(address)) {
602
+ const claimed = this.store.claimUserTurn(address, waiting);
603
+ if (!claimed) break;
604
+ let result;
605
+ try { result = await this.typeTurn(pane, harness, claimed.envelope.body, [claimed.envelope.id]); }
606
+ catch (error) {
607
+ try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ }
608
+ this.log(`mail: the user's turn ${claimed.envelope.id} for ${address} waits: ${error.message}`);
609
+ break;
610
+ }
611
+ // held: the daemon's queue for the session types it when the composer frees, and is not asked again
612
+ if (result?.delivered || result?.queued) { try { this.store.acknowledge(address, claimed); } catch { /* typed */ } typed.push(claimed.envelope.id); }
613
+ else { try { this.store.release(address, claimed); } catch { /* recovered when this process ends */ } break; }
614
+ }
615
+ return typed;
616
+ }
617
+
491
618
  // ---- a filed message handed to its door --------------------------------------------------------------------------
492
619
 
493
620
  /**
package/mail/store.mjs CHANGED
@@ -124,7 +124,9 @@ export class MailStore extends EventEmitter {
124
124
  if (!envelope || typeof envelope.id !== 'string') { box.skipped.add(`${part}/${file}`); return; }
125
125
  // its body's first line is kept beside the header (never in it): a listing of threads names each root by it with no
126
126
  // body read
127
- const firstLine = typeof envelope.body === 'string' ? envelope.body.slice(0, 400).split('\n')[0] : '';
127
+ // the line whole, then capped by characters (a surrogate pair is never split), never a slice of the body first
128
+ const end = typeof envelope.body === 'string' ? envelope.body.indexOf('\n') : -1;
129
+ const firstLine = typeof envelope.body === 'string' ? Array.from(end < 0 ? envelope.body : envelope.body.slice(0, end)).slice(0, 400).join('') : '';
128
130
  box.files.set(`${part}/${file}`, { header: headerOf(envelope), state: STATES[part], path, part, firstLine });
129
131
  }
130
132
 
@@ -207,6 +209,44 @@ export class MailStore extends EventEmitter {
207
209
  return claimed;
208
210
  }
209
211
 
212
+ /** The user's own turns of `address` still waiting for their door, whole, oldest first (mailbox.rs `user_turns`). */
213
+ userTurns(address) {
214
+ const box = this.boxOf(address) ?? this.#named(address);
215
+ if (!box) return [];
216
+ let names = [];
217
+ try { names = readdirSync(join(box.dir, 'new')); } catch { return []; }
218
+ const turns = [];
219
+ for (const file of names) {
220
+ if (!file.endsWith('.json')) continue;
221
+ const path = join(box.dir, 'new', file);
222
+ const envelope = readEnvelope(path);
223
+ if (envelope?.kind !== 'user' || String(envelope.in_reply_to ?? '').startsWith('q-')) continue;
224
+ turns.push({ envelope, state: 'unread', path, part: 'new' });
225
+ }
226
+ return turns.sort(order);
227
+ }
228
+
229
+ /** One of the user's waiting turns taken for typing, so a second typer (the native watch beside this daemon) does not
230
+ * type it too: moved into `claimed/` under this process's pid; null when another typer took it first (mailbox.rs
231
+ * `claim_user_turn`). Hand it on and acknowledge it, or release it to wait again. */
232
+ claimUserTurn(address, waiting) {
233
+ const box = this.boxOf(address) ?? this.#named(address);
234
+ if (!box) return null;
235
+ this.#recoverAbandoned(box);
236
+ const target = join(box.dir, 'claimed', `${process.pid}.${waiting.path.split(/[\\/]/).pop()}`);
237
+ try { renameSync(waiting.path, target); }
238
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
239
+ return { ...waiting, path: target, part: 'claimed' };
240
+ }
241
+
242
+ /** A claimed envelope back to waiting, as it was before it was claimed (mailbox.rs `release`). */
243
+ release(address, claimed) {
244
+ const box = this.boxOf(address) ?? this.#named(address);
245
+ if (!box) return;
246
+ const name = claimed.path.split(/[\\/]/).pop();
247
+ renameSync(claimed.path, join(box.dir, 'new', name.includes('.') ? name.slice(name.indexOf('.') + 1) : name));
248
+ }
249
+
210
250
  /** A claimed envelope reached its reader: moved to `cur/` under its own name (mailbox.rs `acknowledge`). */
211
251
  acknowledge(address, claimed) {
212
252
  const box = this.boxes.get(this.byAddress.get(address));
package/mail/verbs.mjs CHANGED
@@ -19,8 +19,8 @@ export class AgentVerbs {
19
19
  /** `machine` this machine's name; `store` the mail store; `agents` an agent's records; `live` the live-session map;
20
20
  * `supercodeBin` the native binary (`supercode open` starts a session in a pane); `paneOf(session)` the daemon pane a
21
21
  * live session runs in; `mailRoot` the mail folder. */
22
- constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot }) {
23
- Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot });
22
+ constructor({ machine, store, agents, live, supercodeBin, env = process.env, paneOf, mailRoot, closePane = async () => {} }) {
23
+ Object.assign(this, { machine, store, agents, live, supercodeBin, env, paneOf, mailRoot, closePane });
24
24
  }
25
25
 
26
26
  #whole(stored) { const envelope = this.store.body(stored); return envelope ? { ...stored, envelope } : null; }
@@ -126,17 +126,26 @@ export class AgentVerbs {
126
126
  child.on('close', (code) => resolve({ code, out, err }));
127
127
  });
128
128
  if (run.code !== 0) throw new Error(`the session did not start: ${run.err.trim()}`);
129
+ let pane = null, reused = false;
130
+ try { const printed = JSON.parse(run.out); pane = printed.pane; reused = printed.reused === true; } catch { /* not reported */ }
129
131
  if (known) return known;
130
- let pane = null;
131
- try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
132
132
  if (typeof pane !== 'string') throw new Error('the new pane was not reported');
133
- const started = Date.now();
134
- while (Date.now() - started < 60_000) {
135
- const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
136
- if (session) { await ready(session.address); return session.address; }
137
- await sleep(500);
133
+ // a launch that fails once the pane it opened is there closes that pane: nothing it started is left running, holding
134
+ // what was copied to it, after its thread goes back. A pane its key named already (`reused`, a live pane an earlier
135
+ // launch opened) is not this launch's, and is never closed by it.
136
+ try {
137
+ const started = Date.now();
138
+ while (Date.now() - started < 60_000) {
139
+ const session = this.live.sessions().find((row) => this.paneOf(row) === pane);
140
+ if (session) { await ready(session.address); return session.address; }
141
+ await sleep(500);
142
+ }
143
+ throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
144
+ } catch (error) {
145
+ if (reused) { error.message = error.message.replace('; its pane is closed', `; its pane ${pane} was opened before, and is left open`); throw error; }
146
+ try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
147
+ throw error;
138
148
  }
139
- throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute`);
140
149
  }
141
150
 
142
151
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.87",
3
+ "version": "0.3.89",
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",
@@ -70,7 +70,7 @@
70
70
  "@volter/supercode-health": "^0.1.0",
71
71
  "@volter/supercode-terminal": "^0.2.54",
72
72
  "@volter/supercode-trace": "^0.1.0",
73
- "@volter/teams": "0.2.32",
73
+ "@volter/teams": "0.2.33",
74
74
  "@xterm/addon-serialize": "^0.14.0",
75
75
  "@xterm/headless": "^6.0.0",
76
76
  "protobufjs": "8.8.0",