@volter/supercode-teams 0.3.88 → 0.3.90

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,29 @@ 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
+ * 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) {
67
+ try {
68
+ 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;
70
+ } catch (error) {
71
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return null;
72
+ return false;
73
+ }
74
+ }
75
+
52
76
  /** Why a claim names no caller. */
53
77
  export class CallerRefused extends Error {
54
78
  constructor(message) { super(message); this.code = 403; }
@@ -284,8 +308,44 @@ export class LiveSessions extends EventEmitter {
284
308
  if (this.runtimes.delete(id)) this.#changed();
285
309
  return;
286
310
  }
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 });
311
+ this.#holdRuntime(id, receipt);
312
+ }
313
+
314
+ /**
315
+ * A live-runtime receipt held in the map, its process's start checked once (on Linux from /proc, on macOS by one `ps`,
316
+ * as a Claude entry's is): a process that started after its receipt was written is its pid used again, and the
317
+ * receipt is let go, never named as that runtime.
318
+ */
319
+ #holdRuntime(id, receipt) {
320
+ const source = receipt.source;
321
+ 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
+ // 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 } };
324
+ const known = this.runtimes.get(id);
325
+ if (known && known.pid === entry.pid && known.startChecked) entry.startChecked = true;
326
+ this.runtimes.set(id, entry);
288
327
  this.#changed();
328
+ if (entry.startChecked || !Number.isFinite(entry.created)) return;
329
+ entry.startChecked = true;
330
+ void this.#samePid(entry).then((same) => {
331
+ if (same || this.runtimes.get(id) !== entry) return;
332
+ this.runtimes.delete(id);
333
+ 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`);
334
+ this.#changed();
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Whether the process on a held runtime's pid is still the runtime its receipt names: one that started no later than
340
+ * the receipt was written (ps and /proc say a start to the second, so within 2 s), or, when its start reads later,
341
+ * one that answers as the runtime at the receipt's address (a wall clock that stepped forward makes a live runtime's
342
+ * start read late; a pid used again does not answer as it).
343
+ */
344
+ async #samePid(entry) {
345
+ if (!alive(entry.pid)) return false;
346
+ const started = await startedMs(entry.pid);
347
+ if (!Number.isFinite(started) || !Number.isFinite(entry.created) || started <= entry.created + 2000) return true;
348
+ return (await answersAs(entry.probe)) !== false;
289
349
  }
290
350
 
291
351
  /** One messaging-tools marker: the session process named by its file, the server pid in it. */
@@ -425,10 +485,7 @@ export class LiveSessions extends EventEmitter {
425
485
  if (typeof receiptId !== 'string' || !/^[0-9a-f]+$/i.test(receiptId)) return null;
426
486
  const receipt = readReceiptFile(join(this.receiptsDir, `${receiptId}.json`));
427
487
  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
- }
488
+ if (!this.runtimes.has(receiptId)) this.#holdRuntime(receiptId, receipt);
432
489
  return receipt;
433
490
  }
434
491
 
@@ -453,11 +510,13 @@ export class LiveSessions extends EventEmitter {
453
510
  }
454
511
 
455
512
  /** 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, runtime }) {
513
+ async #sessionOfClaim({ pid, ppid, session, runtime }) {
457
514
  // a runtime supercode hosts (the native agent) says so in its claim: it is named by its own process first, never by
458
515
  // the session whose shell started it; one with no receipt is named by the rest of its claim, as any client is
459
516
  if (runtime === true) {
460
- const own = this.#runtimeOfPid(Number(pid));
517
+ // a pid several receipts name is no one runtime: the rest of the claim names it, never a refusal here
518
+ let own = null;
519
+ try { own = await this.#runtimeOfPid(Number(pid)); } catch (error) { if (!(error instanceof CallerRefused) || error.unconfirmed) throw error; }
461
520
  if (own) return own;
462
521
  }
463
522
  const parent = this.claude.get(Number(ppid));
@@ -471,18 +530,26 @@ export class LiveSessions extends EventEmitter {
471
530
  // only when exactly one live runtime's receipt names that process. A process several receipts name (harness serve
472
531
  // records its own pid on each runtime it hosts with no child of its own) is no one runtime, and is refused.
473
532
  for (const candidate of [Number(pid), Number(ppid)]) {
474
- const found = this.#runtimeOfPid(candidate);
533
+ const found = await this.#runtimeOfPid(candidate);
475
534
  if (found) return found;
476
535
  }
477
536
  return null;
478
537
  }
479
538
 
480
539
  /** The runtime whose live receipt alone names process `pid`, or null; several receipts naming it are no one runtime, refused. */
481
- #runtimeOfPid(pid) {
540
+ async #runtimeOfPid(pid) {
482
541
  if (!Number.isInteger(pid) || pid <= 0) return null;
483
542
  const held = [...this.runtimes.values()].filter((runtime) => runtime.pid === pid && alive(runtime.pid));
484
543
  if (held.length > 1) throw new CallerRefused(`process ${pid} hosts ${held.length} runtimes, so which one is calling is not known; nothing was done`);
485
- return held.length === 1 ? this.#address(held[0].harness, held[0].sessionId) : null;
544
+ if (held.length !== 1) return null;
545
+ // a caller's identity: checked again now, with no process (the runtime asked at its own address), as a runtime that
546
+ // 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
549
+ // 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 });
551
+ if (!answered) return null;
552
+ return this.#address(held[0].harness, held[0].sessionId);
486
553
  }
487
554
 
488
555
  /**
@@ -490,7 +557,7 @@ export class LiveSessions extends EventEmitter {
490
557
  * calling"): a session's address, a board's address, or null for a person. Throws CallerRefused when the claim names
491
558
  * a caller the map does not hold.
492
559
  */
493
- callerOf(claim) {
560
+ async callerOf(claim) {
494
561
  const { pid, owner, named } = claim ?? {};
495
562
  const board = typeof named === 'string' ? new RegExp(`^sc:${this.machine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:([^:\\s]+)$`).exec(named) : null;
496
563
  // a board's owner, by the pid it registered with: the client itself, or the owner that started it as the board
@@ -499,7 +566,7 @@ export class LiveSessions extends EventEmitter {
499
566
  if (registered && [pid, Number(owner)].includes(registered) && alive(registered)) return named;
500
567
  // A board's name from anything but its owner is not taken (a card's session, a pane run for the board): the caller
501
568
  // is the session the claim resolves to, or a person, as before (a board's name stood only under its own process).
502
- const session = this.#sessionOfClaim(claim ?? {});
569
+ const session = await this.#sessionOfClaim(claim ?? {});
503
570
  if (session) return session;
504
571
  if (board) return null;
505
572
  // 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,11 +644,21 @@ 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
664
  paneOf: (session) => this.mail.paneOf(session), mailRoot,
@@ -941,6 +953,8 @@ export class TeamsMachine extends MachineCore {
941
953
  this.mailWatch?.kill('SIGTERM');
942
954
  this.stoppingServe = true;
943
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');
944
958
  this.serve?.kill('SIGTERM');
945
959
  this.host.dispose();
946
960
  }
@@ -987,7 +1001,9 @@ export class TeamsMachine extends MachineCore {
987
1001
  };
988
1002
  // a write into a serve that has gone (EPIPE, ECONNRESET) is its pipe's error: the serve is over, the daemon is not
989
1003
  child.stdin.on('error', (error) => {
990
- 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`);
991
1007
  try { child.kill('SIGTERM'); } catch { /* gone */ }
992
1008
  // one that does not end on SIGTERM within 5 s is killed: a serve that no longer reads is never left behind
993
1009
  const grace = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch { /* gone */ } } }, 5000);
@@ -999,7 +1015,7 @@ export class TeamsMachine extends MachineCore {
999
1015
  child.stderr.on('error', () => {});
1000
1016
  child.on('error', (error) => end(`could not run (${error.message})`));
1001
1017
  child.on('exit', (code, signal) => {
1002
- 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 });
1003
1019
  end(`exited (${signal ?? `code ${code}`})`);
1004
1020
  });
1005
1021
  this.serve = child;
@@ -1262,7 +1278,7 @@ export class TeamsMachine extends MachineCore {
1262
1278
 
1263
1279
  #noticeTarget(address){
1264
1280
  return sessionNoticeTarget(address,{machine:machineName(this.env),
1265
- // 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)
1266
1282
  mail:async()=>({code:0,sessions:this.live.sessions()}),
1267
1283
  discover:params=>this.#serveRequest('harness.v1.sessions.discover',params,10000),
1268
1284
  recordedConfig:locator=>this.#serveRequest('harness.v1.sessions.recorded_config',{locator},15000)});
@@ -1276,9 +1292,10 @@ export class TeamsMachine extends MachineCore {
1276
1292
  if(status.state==='running')return;
1277
1293
  if(!status.resumable)throw Object.assign(new Error(status.reason??'native session cannot resume'),{noticeCode:'session_resume_unavailable'});
1278
1294
  const code=await new Promise((resolve,reject)=>{
1279
- // Explicit native approval and sandbox flags suppress open's unattended defaults.
1280
- const child=spawn(this.supercodeBin,['open',address,'--detach','--',...status.resume.args],
1281
- {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'});
1282
1299
  const timer=setTimeout(()=>{child.kill('SIGTERM');resolve(null);},15000);
1283
1300
  child.once('error',error=>{clearTimeout(timer);reject(error);});child.once('close',code=>{clearTimeout(timer);resolve(code);});
1284
1301
  });
@@ -1360,7 +1377,7 @@ export class TeamsMachine extends MachineCore {
1360
1377
  })();
1361
1378
  }
1362
1379
 
1363
- /** 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
1364
1381
  * refused when it came through a link (never forwarded on this machine's own credential), and this machine's own
1365
1382
  * daemon sends on through the one way out. */
1366
1383
  async #sendHere(request, link) {
@@ -1373,7 +1390,7 @@ export class TeamsMachine extends MachineCore {
1373
1390
  }, { link });
1374
1391
  }
1375
1392
 
1376
- /** 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
1377
1394
  * sender is resolved here from the link it came through, never from the filer's own claim; a wake it carries is
1378
1395
  * requested once, for a message new here. */
1379
1396
  #fileHere(request, link) {
@@ -1504,8 +1521,9 @@ export class TeamsMachine extends MachineCore {
1504
1521
  return refuse(`no interact grant for ${target}`);
1505
1522
  }
1506
1523
  accept();
1507
- // The user-turn door every supercode has (harness.v1 sessions.message, as_user): the session's runtime input,
1508
- // 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.
1509
1527
  const answer = (value) => { if (!stream.closed) { stream.write(JSON.stringify(value)); stream.close('delivered'); } };
1510
1528
  // A keyed line is typed once: the server retries a line whose open outlived its bound, and the attempt it gave up
1511
1529
  // on may still be accepted here. A repeat of a key answers what its first attempt answers.
@@ -1528,12 +1546,12 @@ export class TeamsMachine extends MachineCore {
1528
1546
  : request.text;
1529
1547
  const note = { harness, session, text, at: Date.now(), source: { via: 'room', from: { principalId: principal ?? null, name: entry.name ?? null } } };
1530
1548
  try { this.onTurn?.(note); } catch { /* naming is best-effort; the turn goes either way */ }
1531
- this.#serveRequest('harness.v1.sessions.message', {
1532
- locator: { harness, session_id: session, storage: { kind: 'file', path: '' } },
1533
- text,
1534
- from_name: entry.name ?? principal ?? 'a Room member',
1535
- as_user: true,
1536
- }, 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) => {
1537
1555
  const delivered = result?.message_id && !result.refusal;
1538
1556
  this.log.append({ principal, cap: 'interact', target, outcome: delivered ? 'turn' : 'failed', detail: result?.refusal?.message?.slice(0, 200) });
1539
1557
  if (!delivered) try { this.onTurnWithdrawn?.(note); } catch { /* nothing to withdraw */ }
@@ -1709,8 +1727,7 @@ export class TeamsMachine extends MachineCore {
1709
1727
  return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
1710
1728
  }
1711
1729
 
1712
- /** A filing's sender as this machine knows it, with no process and no request (the rule remote-deliver keeps,
1713
- * 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
1714
1731
  * (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
1715
1732
  * reaches this one only through its link, so a return address is never an identity. */
1716
1733
  #observeSender(from) {
@@ -2124,6 +2141,41 @@ export class TeamsMachine extends MachineCore {
2124
2141
 
2125
2142
  /** The program a pane runs, as the terminal package names kinds: what it was
2126
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
+
2127
2179
  #paneKind(contextKey, session) {
2128
2180
  const launched = this.launches.all().find((row) => row.pane === contextKey && row.kind)?.kind;
2129
2181
  const named = String(launched ?? '').trim().toLowerCase();
@@ -3121,34 +3173,9 @@ export class TeamsMachine extends MachineCore {
3121
3173
  if (sessionTurn?.refused) { act('control', `pane:${params.pane}`, 'refused', JSON.stringify(sessionTurn.refused)); return sessionTurn.refused; }
3122
3174
  const session = await this.#paneByContext(params.pane);
3123
3175
  if (!session) throw Object.assign(new Error(`no pane with context ${params.pane}`), { code: 404 });
3124
- if (this.suspended.has(params.pane)) throw Object.assign(new Error(`pane ${params.pane} is suspended: remote input is refused until resume`), { code: 423 });
3125
- const kind = this.#paneKind(params.pane, session);
3126
- // Mail is typed only into its own harness's pane. A process inherits SUPERCODE_TEAMS_PANE from whatever ran it,
3127
- // so a headless `codex exec` a Claude pane's tool started once took that pane for its own, and the Codex's mail
3128
- // was typed into Claude's composer (m-72bc65eb). The addressed harness names the program the text is for. Only a
3129
- // pane known to run another harness is refused: an unrecorded pane's kind is its command's name, which a native
3130
- // Claude install shows as its version.
3131
- const addressed = params.harness == null ? null : String(params.harness).trim().toLowerCase().replace(/^claude$/u, 'claude-code');
3132
- if (addressed && addressed !== kind && ['claude-code', 'codex', 'grok', 'gemini', 'goose', 'opencode', 'pi', 'hermes', 'openclaw'].includes(kind)) {
3133
- const refused = { delivered: false, reason: `harness-mismatch: mail for ${addressed} is not typed into a ${kind || 'unknown'} pane` };
3134
- act('control', `pane:${params.pane}`, 'refused', JSON.stringify(refused));
3135
- return refused;
3136
- }
3137
- if (kind === 'claude-code') {
3138
- // A usage-limit wait would queue this turn until the limit's reset: esc ends the wait first. A limit that
3139
- // still holds refuses the turn at once and the session waits again, for the next turn to try.
3140
- const queued = [...this.terminalMailboxes.values()].some(queue => queue.session?.id === session.id && (queue.state.active || queue.state.pending.length));
3141
- if (!this.inputInFlight.has(session.id) && !queued && claudeUsageLimitWait(await this.host.capture(session.id, { lines: 60 }))) {
3142
- await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', deadlineMs: Date.now() + 5_000 });
3143
- await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
3144
- }
3145
- const draftOf = async () => composerHoldsDraft(kind, await this.host.capture(session.id, { lines: 200, preserveEscapes: true }));
3146
- const first = await draftOf();
3147
- await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
3148
- const second = await draftOf();
3149
- if (first !== false || second !== false) return { delivered: false, reason: first === true || second === true ? 'draft' : 'no-composer' };
3150
- }
3151
- 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;
3152
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 }; }
3153
3180
  act('control', `pane:${params.pane}`, result.delivered ? 'input' : result.queued ? 'input-pending' : 'failed', JSON.stringify(result));
3154
3181
  return result;
@@ -3320,7 +3347,8 @@ export class TeamsMachine extends MachineCore {
3320
3347
  // 3a): this machine's own processes only, answered here.
3321
3348
  case 'harness.v1.mail.list': case 'harness.v1.mail.show': case 'harness.v1.mail.inbox': case 'harness.v1.mail.acknowledge': {
3322
3349
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
3323
- 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() };
3324
3352
  if (method === 'harness.v1.mail.show') {
3325
3353
  const ids = (Array.isArray(params.ids) ? params.ids : []).filter((id) => typeof id === 'string');
3326
3354
  return params.replies ? this.mail.showReplies(ids) : await this.mail.show(ids);
@@ -3352,7 +3380,9 @@ export class TeamsMachine extends MachineCore {
3352
3380
  // acknowledged once it has printed it (harness.v1.mail.acknowledge)
3353
3381
  const caller = await this.#callerOf(entry, params);
3354
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." };
3355
- 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 });
3356
3386
  let token = null;
3357
3387
  if (read.claimed.length) {
3358
3388
  token = randomBytes(12).toString('hex');
@@ -3362,7 +3392,8 @@ export class TeamsMachine extends MachineCore {
3362
3392
  for (const [old] of [...this.inboxClaims].slice(0, Math.max(0, this.inboxClaims.size - 255))) this.inboxClaims.delete(old);
3363
3393
  this.inboxClaims.set(token, { address: caller, claimed: read.claimed });
3364
3394
  }
3365
- 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 };
3366
3397
  }
3367
3398
  // Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
3368
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
@@ -72,26 +72,43 @@ export class Agents {
72
72
  * channel marker. Every writer (this daemon, and mail_agent.rs's in the native watch until step 4) renames a whole
73
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
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): read again, and not kept, until it is older.
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.
76
77
  */
77
78
  #records() {
78
79
  let changed;
79
80
  try { changed = statSync(this.threadsDir).mtimeMs; } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
80
- if (this.threadsCache?.changed === changed && Date.now() - changed > RACY_MS) return this.threadsCache;
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.
81
86
  let names = [];
82
87
  try { names = readdirSync(this.threadsDir); } catch { return { list: [], byHolder: new Map(), byMarker: new Map() }; }
83
- const list = names.filter((name) => name.endsWith('.json')).map((name) => readJson(join(this.threadsDir, name))).filter((thread) => thread?.id)
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)
84
99
  .sort((a, b) => (a.created_at_ms - b.created_at_ms) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
85
100
  const byHolder = new Map(), byMarker = new Map();
86
101
  for (const thread of list) {
87
102
  byHolder.set(thread.holder, [...(byHolder.get(thread.holder) ?? []), thread]);
88
103
  for (const [marker, id] of Object.entries(thread.markers ?? {})) if (!byMarker.has(marker)) byMarker.set(marker, { thread, id });
89
104
  }
90
- const records = { changed, list, byHolder, byMarker };
91
- if (Date.now() - changed > RACY_MS) this.threadsCache = records;
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;
92
108
  return records;
93
109
  }
94
110
 
111
+
95
112
  /** The thread `address` holds for an agent other than as its main session (mail_agent.rs `thread_held_by`). */
96
113
  heldBy(address) {
97
114
  const held = [...(this.#records().byHolder.get(address) ?? [])].reverse().find((thread) => {
@@ -325,8 +342,8 @@ export class Agents {
325
342
  return (await this.#resumeArguments(address)) != null;
326
343
  }
327
344
 
328
- /** Resume the stopped session at `address` in a daemon pane, by `supercode open <session> --detach -- <its recorded
329
- * 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. */
330
347
  async resume(address) {
331
348
  const recorded = await this.#resumeArguments(address);
332
349
  if (!recorded) throw new Error(`${address} has no recorded permissions to resume with; its mail waits`);
@@ -339,7 +356,8 @@ export class Agents {
339
356
  }
340
357
  writeAtomically(join(this.agentsDir, 'resumed', blake3Hex(address).slice(0, 24)), String(Date.now()));
341
358
  const { code, stderr } = await new Promise((resolve) => {
342
- 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 });
343
361
  let err = '';
344
362
  child.stderr.on('data', (chunk) => { err += chunk; });
345
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,40 @@ 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
+ // closed under the relay's own lock: a send to it that arrives meanwhile waits for the close, then starts it
79
+ // again, never handing its turn to a runtime that is closing
80
+ 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}`));
82
+ }
83
+ }
84
+
85
+ /** `work` run under the relay folder's lock: one send, or one close, at a time per relay, in arrival order. */
86
+ async #locked(directory, work) {
87
+ const previous = this.sending.get(directory) ?? Promise.resolve();
88
+ let release;
89
+ const mine = new Promise((resolve) => { release = resolve; });
90
+ const turn = previous.then(() => mine);
91
+ this.sending.set(directory, turn);
92
+ await previous;
93
+ try { return await work(); }
94
+ finally {
95
+ release();
96
+ if (this.sending.get(directory) === turn) this.sending.delete(directory);
97
+ }
62
98
  }
63
99
 
64
100
  /** The relay that speaks for `represented`, known as `representedName`: its name and its files. */
@@ -108,8 +144,9 @@ export class Relays {
108
144
 
109
145
  /** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
110
146
  * `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
111
- async #ensure(spec) {
147
+ async #ensure(spec, step = () => {}) {
112
148
  const endpoint = await this.endpoint();
149
+ step('relay endpoint');
113
150
  if (!endpoint) throw new Error('the relay endpoint is not answering; the machine daemon\'s `supercode message watch` serves it (see mail/machine-daemon.log)');
114
151
  // written on every send: Claude Code reloads a changed settings file, so a running relay takes the hooks this
115
152
  // supercode installs
@@ -118,8 +155,11 @@ export class Relays {
118
155
  if (record?.runtime_id) {
119
156
  const runtime = this.runtimeOf('claude-code', record.runtime_id);
120
157
  if (runtime && alive(this.#pid(spec, runtime)) && record.connection && (record.endpoint === endpoint || await answers(Number(String(record.endpoint ?? '').split(':').pop())))) {
158
+ step('runtime reused');
121
159
  return { runtime, connection: record.connection };
122
160
  }
161
+ // why it is not reused, said in the timing line: a relay that should have been warm and was not shows here
162
+ step(`runtime not reused (${!runtime ? 'no live receipt' : !alive(this.#pid(spec, runtime)) ? 'its process ended' : !record.connection ? 'no connection' : 'endpoint moved'})`);
123
163
  // closed through the owner's door even after its receipt went
124
164
  try { await this.serve('harness.v1.runtimes.close', { connection: record.connection, runtime_id: record.runtime_id }, 15_000); }
125
165
  catch (error) { if (this.runtimeOf('claude-code', record.runtime_id)) throw new Error(`could not retire the old relay: ${error.message}`); }
@@ -129,7 +169,7 @@ export class Relays {
129
169
  harness: 'claude-code',
130
170
  launch: {
131
171
  program: 'claude',
132
- arguments: ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--permission-prompt-tool', 'stdio', '--model', RELAY_MODEL, '--name', spec.name, '--permission-mode', 'bypassPermissions', '--setting-sources', 'project', '--settings', spec.settings, '--tools', 'ListAgents,SendMessage', '--no-session-persistence'],
172
+ arguments: ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--permission-prompt-tool', 'stdio', '--model', RELAY_MODEL, '--name', spec.name, '--permission-mode', 'bypassPermissions', '--setting-sources', 'project', '--settings', spec.settings, '--tools', 'ListAgents,SendMessage', '--no-session-persistence', '--strict-mcp-config'],
133
173
  // its API is the relay endpoint, its key the relay's folder there (which also keeps account connectors out)
134
174
  env: { ANTHROPIC_API_KEY: key, ANTHROPIC_BASE_URL: endpoint, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', SUPERCODE_CLAUDE_RELAY: '1' },
135
175
  },
@@ -139,18 +179,27 @@ export class Relays {
139
179
  if (typeof runtimeId !== 'string') throw new Error(`the machine daemon did not start the relay: ${JSON.stringify(started)}`);
140
180
  const connection = typeof started.connection === 'string' ? started.connection : '';
141
181
  const pid = Number.isInteger(started?.handle?.endpoint?.pid) ? started.handle.endpoint.pid : null;
182
+ step('runtime started (runtimes.start)');
142
183
  writeFileSync(spec.record, JSON.stringify({ runtime_id: runtimeId, pid, endpoint, connection }));
184
+ // the daemon's from its start: counted by the cap even when the rest of this start fails
185
+ this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
143
186
  // harness serve writes its receipt before it answers the start; the map learns it from its watch a moment later, so
144
187
  // one it does not hold yet is read by its own name (the receipt the runtime's terminal launch opens), never waited on
145
188
  const known = this.runtimeOf('claude-code', runtimeId);
146
- if (known) return { runtime: known, connection };
189
+ if (known) { step('its receipt held'); return { runtime: known, connection }; }
147
190
  let receiptId = null;
148
191
  try {
149
192
  const launch = (await this.serve('harness.v1.runtimes.terminal_instructions', { connection }, 5000))?.launch;
150
193
  receiptId = (Array.isArray(launch?.arguments) ? launch.arguments : []).find((arg) => typeof arg === 'string' && arg.startsWith('supercode-live://'))?.slice('supercode-live://'.length) ?? null;
151
194
  } catch { /* said below */ }
152
195
  const runtime = receiptId ? this.runtimeNamed(receiptId) : null;
153
- if (!runtime) throw new Error('the relay started but registered no live runtime');
196
+ step('its receipt read');
197
+ if (!runtime) {
198
+ // a runtime no send can use is closed, never left running untracked
199
+ this.running.delete(spec.directory);
200
+ try { await this.serve('harness.v1.runtimes.close', { connection, runtime_id: runtimeId }, 15_000); } catch { /* said below */ }
201
+ throw new Error('the relay started but registered no live runtime; it was closed');
202
+ }
154
203
  return { runtime, connection };
155
204
  }
156
205
 
@@ -180,28 +229,30 @@ export class Relays {
180
229
  async send(sender, senderName, receiver, message, messageId) {
181
230
  const spec = this.spec(sender, senderName);
182
231
  mkdirSync(spec.directory, { recursive: true });
183
- const previous = this.sending.get(spec.directory) ?? Promise.resolve();
184
- let release;
185
- const mine = new Promise((resolve) => { release = resolve; });
186
- const turn = previous.then(() => mine);
187
- this.sending.set(spec.directory, turn);
188
- await previous;
232
+ const asked = performance.now();
233
+ try { return await this.#locked(spec.directory, () => this.#send(spec, receiver, message, messageId, asked)); }
234
+ finally { this.#cap(); }
235
+ }
236
+
237
+ async #send(spec, receiver, message, messageId, asked) {
189
238
  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
239
  const queued = { to: receiver.socket ? `uds:${receiver.socket}` : receiver.name, message };
194
- let { runtime, connection } = await this.#ensure(spec);
240
+ const steps = [], began = asked;
241
+ let mark = began;
242
+ const step = (name) => { const now = performance.now(); steps.push(`${name} ${Math.round(now - mark)} ms`); mark = now; };
243
+ step('lock');
244
+ let { runtime, connection } = await this.#ensure(spec, step);
245
+ this.#used(spec, connection);
195
246
  rmSync(spec.receipt, { force: true });
196
247
  writeFileSync(spec.queue, JSON.stringify(queued));
197
248
  let receipt;
198
249
  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)); }
250
+ 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
251
  catch (error) { receipt = { delivered: false, detail: `could not reach the Claude relay: ${error.message}` }; }
201
252
  const pid = this.#pid(spec, runtime);
202
253
  // only a relay proven dead is started again; one alive but slow may still deliver
203
254
  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; }
255
+ try { ({ runtime, connection } = await this.#ensure(spec, step)); this.#used(spec, connection); } catch (error) { receipt = { delivered: false, detail: error.message }; break; }
205
256
  rmSync(spec.receipt, { force: true });
206
257
  }
207
258
  rmSync(spec.queue, { force: true });
@@ -210,15 +261,20 @@ export class Relays {
210
261
  sent[receiver.sessionId] = messageId;
211
262
  writeFileSync(spec.sent, JSON.stringify(sent));
212
263
  }
264
+ // a slow relay send says where its time went, in the daemon's log (what a person reads of the connector)
265
+ 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
266
  return receipt;
214
267
  } catch (error) {
215
268
  rmSync(spec.queue, { force: true });
216
269
  return { delivered: false, detail: error.message };
217
- } finally {
218
- release();
219
- if (this.sending.get(spec.directory) === turn) this.sending.delete(spec.directory);
220
270
  }
221
271
  }
272
+
273
+ /** A relay this daemon runs, used now. */
274
+ #used(spec, connection) {
275
+ const runtimeId = readJson(spec.record)?.runtime_id;
276
+ if (typeof runtimeId === 'string') this.running.set(spec.directory, { connection, runtime_id: runtimeId, usedAt: Date.now() });
277
+ }
222
278
  }
223
279
 
224
280
  /** 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
@@ -209,6 +209,44 @@ export class MailStore extends EventEmitter {
209
209
  return claimed;
210
210
  }
211
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
+
212
250
  /** A claimed envelope reached its reader: moved to `cur/` under its own name (mailbox.rs `acknowledge`). */
213
251
  acknowledge(address, claimed) {
214
252
  const box = this.boxes.get(this.byAddress.get(address));
package/mail/verbs.mjs CHANGED
@@ -126,12 +126,13 @@ 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;
130
- try { pane = JSON.parse(run.out).pane; } catch { /* not reported */ }
129
+ let pane = null, reused = false;
130
+ try { const printed = JSON.parse(run.out); pane = printed.pane; reused = printed.reused === true; } catch { /* not reported */ }
131
131
  if (known) return known;
132
132
  if (typeof pane !== 'string') throw new Error('the new pane was not reported');
133
- // a launch that fails once its pane is open closes that pane: nothing it started is left running, holding what was
134
- // copied to it, after its thread goes back
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.
135
136
  try {
136
137
  const started = Date.now();
137
138
  while (Date.now() - started < 60_000) {
@@ -141,6 +142,7 @@ export class AgentVerbs {
141
142
  }
142
143
  throw new Error(`${program} started in pane ${pane}, but no session appeared there within a minute; its pane is closed`);
143
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; }
144
146
  try { await this.closePane(pane); } catch (closing) { error.message += ` (its pane ${pane} could not be closed: ${closing.message})`; }
145
147
  throw error;
146
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.88",
3
+ "version": "0.3.90",
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",