@volter/supercode-teams 0.3.159 → 0.3.161

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/machine.mjs CHANGED
@@ -654,7 +654,7 @@ export class TeamsMachine extends MachineCore {
654
654
  if (!allowed.size) return refuse(RECORDS_NOT_GRANTED);
655
655
  if (!this.records) return refuse('this machine keeps no record yet');
656
656
  accept();
657
- const unsubscribe = this.records.producer.subscribe((frame) => { try { stream.write(frame); } catch { unsubscribe(); } }, allowed);
657
+ const unsubscribe = this.records.producer.subscribe((frame) => { stream.write(frame); }, allowed, stream.params ?? {});
658
658
  const readmit = local ? null : setInterval(() => void (async () => {
659
659
  const admitted = await entry.verify?.().catch(() => null);
660
660
  const now = admitted ? sectionsFor(admitted, false) : new Set();
@@ -663,6 +663,29 @@ export class TeamsMachine extends MachineCore {
663
663
  readmit?.unref?.();
664
664
  stream.on('close', () => { clearInterval(readmit); unsubscribe(); });
665
665
  });
666
+ // Held peer records never leave this OS user's local socket. Apps and the
667
+ // resident board follow committed row changes instead of full fleet copies.
668
+ this.registerDoor('machine-record-changes', (entry, stream, accept, refuse) => {
669
+ if (entry.principal !== 'local' || entry.appDoor) return refuse('held machine records are read only by this machine\'s own processes');
670
+ if (!this.records) return refuse('this daemon keeps no records yet');
671
+ accept();
672
+ const changes = this.mailStore.changes;
673
+ let cursor = stream.params?.after ?? null;
674
+ const stop = changes.follow((page) => {
675
+ if (page.error) return stream.close(page.error);
676
+ if (page.reset) {
677
+ const snapshot = this.#recordSnapshot();
678
+ cursor = changes.cursor();
679
+ stream.write({ type: 'snapshot', cursor, ...snapshot });
680
+ return cursor;
681
+ }
682
+ cursor = page.cursor;
683
+ stream.write({ type: 'changes', ...page });
684
+ }, { after: cursor, topics: ['record-head', 'record-row'], initial: true });
685
+ const beat = setInterval(() => { try { stream.write({ type: 'beat', cursor, at: Date.now() }); } catch { stop(); clearInterval(beat); } }, 15_000);
686
+ beat.unref?.();
687
+ stream.on('close', () => { clearInterval(beat); stop(); });
688
+ });
666
689
  // This machine's processes read the team's published events here, from the one stream its connector holds
667
690
  // (machine-events.mjs PublishedEvents); a caller it refuses reads the server itself.
668
691
  this.registerDoor('events', (entry, stream, accept, refuse) => {
@@ -826,13 +849,19 @@ export class TeamsMachine extends MachineCore {
826
849
  runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
827
850
  runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
828
851
  endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
852
+ inboundSettled: () => this.mailHooks?.drainPoolReplies(),
829
853
  arrived: (sessionId, landed) => claudeTranscriptHolds([...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')), sessionId, landed),
830
854
  log: (line) => this.emit('mail-log', line) });
831
855
  // a turn or a wake typed into a daemon pane's empty composer, in process (as `panes.turn` types one)
832
- const typeTurn = async (pane, harness, text, messageIds) => {
856
+ const typeTurn = async (pane, harness, text, messageIds, address) => {
833
857
  if (this.stopping) throw new Error('the daemon is stopping; no pane input was submitted');
834
858
  const session = await this.#paneByContext(pane);
835
859
  if (!session) throw new Error(`no pane with context ${pane}`);
860
+ const expected = parseAddress(address);
861
+ if (!expected || expected.machine !== machine || expected.harness !== harness) return { delivered: false, reason: 'mail recipient binding is missing' };
862
+ const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
863
+ const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
864
+ if (native?.harness !== expected.harness || native.session_id !== expected.session) return { delivered: false, reason: 'the pane no longer runs the addressed native session; no input submitted' };
836
865
  const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
837
866
  if (result.refused) throw new Error(result.refused.reason);
838
867
  return result.waits ?? result;
@@ -859,11 +888,13 @@ export class TeamsMachine extends MachineCore {
859
888
  // minute, and before each relay's inbound call
860
889
  this.hookSpool = join(supercodeHome(this.env), 'hook-spool', 'relay-inbound');
861
890
  this.mailHooks.drainSpool(this.hookSpool);
891
+ this.mailHooks.drainPoolReplies();
862
892
  this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
863
893
  // the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
864
894
  this.carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
865
895
  handOver: (address, id) => this.mailSend.handOver(address, id),
866
- typeTurn, askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
896
+ typeTurn, typeUserTurns: (address, pane) => this.mailSend.typeUserTurns(address, pane), paneOf: (session) => this.mail.paneOf(session),
897
+ askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
867
898
  log: (line) => this.emit('mail-log', line) }).start();
868
899
  // idle notices and final replies (step 4c): each subscribed session's activity followed through harness serve
869
900
  this.idleNotices = new IdleNotices({ machine, store: this.mailStore, live,
@@ -877,26 +908,7 @@ export class TeamsMachine extends MachineCore {
877
908
  claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
878
909
  lastMessageAt: (address) => this.sessionDescriptors?.get(address)?.updated_at_ms ?? null,
879
910
  program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) }).start();
880
- // the user's own turns that wait in a pane session's mailbox (a draft in its composer when they came) are typed once
881
- // its composer is free: tried at each change of its mailbox, and every 2 s while one waits (mail_watch.rs
882
- // `deliver_waiting_user_turns` read every pane's mailbox every 2 s)
883
- const turnsWaiting = new Map(); // address → timer
884
- const typeWaiting = async (address) => {
885
- clearTimeout(turnsWaiting.get(address)); turnsWaiting.delete(address);
886
- if (this.stopping) return;
887
- const session = live.sessions().find((row) => row.address === address);
888
- const pane = session ? this.mail.paneOf(session) : null;
889
- if (!pane || !this.mailStore.userTurns(address).length) return;
890
- try { await this.mailSend.typeUserTurns(address, pane); } catch (error) { this.emit('mail-log', `the user's turns for ${address} wait: ${error.message}`); }
891
- if (!this.stopping && this.mailStore.userTurns(address).length) { const timer = setTimeout(() => void typeWaiting(address), 2000); timer.unref?.(); turnsWaiting.set(address, timer); }
892
- };
893
- this.mailStore.on('changed', (address) => { if (address && !turnsWaiting.has(address)) void typeWaiting(address); });
894
- // the turns already waiting when this listener came (the store read every mailbox at its start, before it), and every
895
- // mailbox again once per 5 min, so a turn no event named is typed within that bound
896
- const everyMailbox = () => { for (const address of this.mailStore.addresses()) if (!turnsWaiting.has(address)) void typeWaiting(address); };
897
- everyMailbox();
898
- this.turnsSweep = setInterval(everyMailbox, 300_000); this.turnsSweep.unref?.();
899
- this.stopWaitingTurns = () => { clearInterval(this.turnsSweep); for (const timer of turnsWaiting.values()) clearTimeout(timer); turnsWaiting.clear(); };
911
+ // User turns share the persisted delivery schedule; only their own raw-input door types them.
900
912
  // an agent's verbs (3b-2): its threads, a delegation, its record
901
913
  this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
902
914
  paneOf: (session) => this.mail.paneOf(session), mailRoot,
@@ -905,23 +917,7 @@ export class TeamsMachine extends MachineCore {
905
917
  // the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
906
918
  // runs under another, kept from the map's changes (at most once a second)
907
919
  let naming = null;
908
- // a session that has ended has its relay retired (step 4a: by its end, never a minute's sweep); the relays of
909
- // sessions that ended while no daemon ran are retired once, at start, when the live sessions are known
910
- let wasLive = new Map(live.sessions().map((row) => [row.address, row.name]));
911
- live.on('changed', () => {
912
- // while the Codex sessions are not known (a start, a serve restart) a Codex session missing has not ended: the last
913
- // known view stands until they are known again
914
- if (this.stopping || !live.codexKnown) return;
915
- const now = new Map(live.sessions().map((row) => [row.address, row.name]));
916
- // a Codex session that leaves the map has not provably ended (idle over a day, it is no longer followed): kept
917
- for (const [address, name] of wasLive) if (!now.has(address) && address.split(':')[2] !== 'codex') void this.relays.retire(address, name).catch(() => {});
918
- wasLive = now;
919
- });
920
- void live.whenCodexKnown(120_000).then((known) => {
921
- if (this.stopping) return;
922
- if (!known) { this.emit('mail-log', 'relays: the live sessions were not known within two minutes of start; relays of ended sessions are retired at their next end'); return; }
923
- return this.relays.sweepAtStart((address) => live.sessions().some((row) => row.address === address));
924
- }).catch((error) => this.emit('mail-log', `relays: the start sweep failed: ${error.message}`));
920
+ void this.relays.sweepAtStart().catch((error) => this.emit('mail-log', `relay pool: predecessors retained (${error.message})`));
925
921
  live.on('changed', () => {
926
922
  if (this.stopping) return;
927
923
  if (naming) return;
@@ -930,6 +926,16 @@ export class TeamsMachine extends MachineCore {
930
926
  });
931
927
  }
932
928
 
929
+ #recordSnapshot() {
930
+ const self = machineName(this.env);
931
+ if (!this.records) return { machine: self, at: Date.now(), records: {}, unread: 'this daemon keeps no records yet' };
932
+ const following = this.machineEvents ? null : 'this machine\'s daemon runs with no team connector, so it follows no other machine';
933
+ return { machine: self, at: Date.now(), ...(following ? { unread: following } : {}), records: {
934
+ [self]: { record: this.records.producer.record, stream: 'self', since: null, why: null, received_at: Date.now() },
935
+ ...this.records.holder.read(),
936
+ } };
937
+ }
938
+
933
939
  /** One send of this machine's own, made in its daemon's process (the connector's machine events): from the sender
934
940
  * the request names, which is this machine's own operator address. */
935
941
  async sendOwn(request) {
@@ -966,7 +972,7 @@ export class TeamsMachine extends MachineCore {
966
972
 
967
973
  #startRecords() {
968
974
  const machine = machineName(this.env);
969
- const producer = new RecordProducer({ machine, log: (line) => this.emit('records-log', line), readers: {
975
+ const producer = new RecordProducer({ machine, store: this.mailStore, log: (line) => this.emit('records-log', line), readers: {
970
976
  // this machine's sessions, from the live-session map (no process)
971
977
  sessions: async () => {
972
978
  const sessions = await this.mail.localRows();
@@ -974,18 +980,21 @@ export class TeamsMachine extends MachineCore {
974
980
  return sessions;
975
981
  },
976
982
  panes: async () => {
977
- const rows = await this.attentionEvidence();
983
+ let rows;
984
+ try { rows = await this.attentionEvidence(); }
985
+ catch (error) { this.live?.panes([], { current: false }); throw error; }
978
986
  const titles = readJson(this.p.titles, { v: 1, titles: {} }).titles;
979
987
  const panes = rows.filter((row) => row.session?.id).map(({ session: s }) => ({ pane: s.contextKey, dead: Boolean(s.dead), pid: s.rootPid ?? null, label: s.label ?? null, title: titles[`pane:${s.contextKey}`] ?? s.label ?? null, cwd: s.cwd ?? null }));
980
988
  const live = panes.filter((pane) => !pane.dead && pane.pid);
981
989
  // the pane each session whose record names none runs in (Grok's; Claude's on Windows): the one rooted at it
982
- this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid })));
983
990
  // the harness session under each live pane, as the board reads it: unread is said, never no session
984
991
  let activities = [], sessions_unread = null;
985
992
  if (live.length) {
986
993
  try { activities = (await this.#serveRequest('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }, 30_000))?.activities ?? []; }
987
994
  catch (error) { sessions_unread = String(error?.message ?? error).slice(0, 200); }
988
995
  }
996
+ this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid,
997
+ activity: sessions_unread ? null : activities.find(row => row.pid === pane.pid)?.activity ?? null })), { current: sessions_unread == null });
989
998
  // the sessions under the panes unread: the panes are read again on the section's backoff until they are (MR4)
990
999
  if (sessions_unread) producer.retry('panes', null);
991
1000
  else this.live?.codexPanes(activities.map((row) => ({ pane: live.find((pane) => pane.pid === row.pid)?.pane ?? null, activity: row.activity })));
@@ -1001,12 +1010,12 @@ export class TeamsMachine extends MachineCore {
1001
1010
  return answer.mail;
1002
1011
  },
1003
1012
  } });
1004
- const holder = new RecordHolder({ self: machine, file: join(this.home, 'machine-records.json'), log: (line) => this.emit('records-log', line),
1013
+ const holder = new RecordHolder({ self: machine, store: this.mailStore, file: join(this.home, 'machine-records.json'), log: (line) => this.emit('records-log', line),
1005
1014
  // the holder's sink is attached at once, in the same turn the stream is handed over: nothing it delivers is missed
1006
1015
  open: async (peer, sink) => {
1007
1016
  const channel = await openMachine({ machine: peer, env: this.env });
1008
1017
  let stream;
1009
- try { stream = await channel.open('machine-records', {}); } catch (error) { channel.close(); throw error; }
1018
+ try { stream = await channel.open('machine-records', sink.resume ?? {}); } catch (error) { channel.close(); throw error; }
1010
1019
  stream.on('data', sink.data);
1011
1020
  stream.on('close', (reason) => { channel.close(); sink.close(reason); });
1012
1021
  stream.on('error', (error) => sink.close(error?.message));
@@ -1121,9 +1130,15 @@ export class TeamsMachine extends MachineCore {
1121
1130
  start();
1122
1131
  };
1123
1132
  this.recordsWatches = new Set(); this.recordsRetries = new Set();
1124
- // the mail folder's changes, as the header index takes them (its own watch; the mailbox each names), and its state
1133
+ // Mail rows follow their durable sequence. Reconciliation/gaps name a
1134
+ // snapshot; ordinary pages touch only the mailboxes whose rows changed.
1125
1135
  const mailState = () => producer.note('mail_watch', this.mailStore.unread ?? null);
1126
- this.mailStore.on('changed', (address) => { mailState(); producer.touch('mail', address); });
1136
+ this.stopRecordMail = this.mailStore.changes.follow((page) => {
1137
+ if (this.recordsStopped || page.error) return;
1138
+ mailState();
1139
+ if (page.reset) producer.touch('mail');
1140
+ else for (const address of new Set(page.changes.map((change) => change.key.address).filter(Boolean))) producer.touch('mail', address);
1141
+ }, { after: this.mailStore.changes.cursor(), topics: ['message', 'delivery', 'claim'] });
1127
1142
  mailState();
1128
1143
  // the live sessions' changes, as the live-session map takes them (Claude's registry in every home it follows, the
1129
1144
  // hosted runtimes, Codex), and whether each of its sources is current
@@ -1208,6 +1223,7 @@ export class TeamsMachine extends MachineCore {
1208
1223
  for (const retry of this.recordsRetries ?? []) clearTimeout(retry);
1209
1224
  for (const watcher of this.recordsWatches ?? []) { try { watcher.close(); } catch { /* closed */ } }
1210
1225
  this.records?.producer.stop(); this.records?.holder.close();
1226
+ this.stopRecordMail?.();
1211
1227
  for (const child of this.voiceProcesses.values()) child.kill('SIGTERM');
1212
1228
  this.voiceProcesses.clear();
1213
1229
  this.jobs?.stopAll();
@@ -1215,7 +1231,6 @@ export class TeamsMachine extends MachineCore {
1215
1231
  this.stopLiveFollow?.();
1216
1232
  this.#stopMailWorkers();
1217
1233
  this.mailStore?.stop();
1218
- clearInterval(this.turnsSweep);
1219
1234
  this.stoppingServe = true;
1220
1235
  clearTimeout(this.serveRestart);
1221
1236
  // said, so a stop reads as one in the log and never as a serve that broke
@@ -1237,7 +1252,6 @@ export class TeamsMachine extends MachineCore {
1237
1252
  this.nativeQuestions?.stop();
1238
1253
  this.mailHooks?.stop();
1239
1254
  this.relays?.stop();
1240
- this.stopWaitingTurns?.();
1241
1255
  }
1242
1256
 
1243
1257
  connectionClosed(index) {
@@ -3580,11 +3594,7 @@ export class TeamsMachine extends MachineCore {
3580
3594
  // this machine's record and the held records of the machines it follows, each with how current it is: for this
3581
3595
  // machine's own processes only (the records it holds are other machines', given to it, never passed on)
3582
3596
  if (principal !== 'local') { this.log.append({ principal, cap: method, target: '*', outcome: 'refused' }); throw Object.assign(new Error('machine records are read by this machine\'s own processes'), { code: 403 }); }
3583
- if (!this.records) return { machine: machineName(this.env), at: Date.now(), records: {}, unread: 'this daemon keeps no records yet' };
3584
- const self = machineName(this.env);
3585
- // a daemon with no team presence stream follows no other machine: said, so no reader takes the team as empty
3586
- const following = this.machineEvents ? null : 'this machine\'s daemon runs with no team connector, so it follows no other machine';
3587
- return { machine: self, at: Date.now(), ...(following ? { unread: following } : {}), records: { [self]: { record: this.records.producer.record, stream: 'self', since: null, why: null, received_at: Date.now() }, ...this.records.holder.read() } };
3597
+ return { ...this.#recordSnapshot(), cursor: this.mailStore.changes.cursor() };
3588
3598
  }
3589
3599
  case 'harness.v1.panes.list':
3590
3600
  case 'panes.list': {
@@ -4220,8 +4230,8 @@ export class TeamsMachine extends MachineCore {
4220
4230
  // `via`: the door the claims record (`inbox`, or `read_messages` for `message mcp`'s tool)
4221
4231
  return this.#claimInbox(caller, { all: params.all === true, last, pid: Number(params.claim?.pid) || process.pid, via: params.via === 'read_messages' ? 'read_messages' : 'inbox' });
4222
4232
  }
4223
- // `message wait` (step 1): the caller's next unread mail (from one sender, with `from`), waited for on the store's
4224
- // own change events, at most 100 s, then claimed as its inbox claims it; nothing polls
4233
+ // `message wait`: one durable cursor followed for the caller's mailbox,
4234
+ // then the ordinary tokenized claim. No folder scan or read/listen gap.
4225
4235
  case 'harness.v1.mail.wait': {
4226
4236
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
4227
4237
  const caller = await this.#callerOf(entry, params);
@@ -4233,16 +4243,12 @@ export class TeamsMachine extends MachineCore {
4233
4243
  // waiting for its pane, so a wait never wakes for mail it then cannot claim; an abandoned claim is given back first
4234
4244
  const waiting = () => this.mailStore.hasClaimable(caller, wanted ? (envelope) => envelope.from === wanted || String(envelope.from_name ?? '').startsWith(wanted) : null);
4235
4245
  if (!waiting()) {
4236
- const arrived = await new Promise((resolve) => {
4237
- const changed = (address) => { if (address === caller && waiting()) done(true); };
4238
- const done = (value) => { clearTimeout(timer); this.mailStore.off('changed', changed); resolve(value); };
4239
- const timer = setTimeout(() => done(false), seconds * 1000);
4240
- this.mailStore.on('changed', changed);
4241
- });
4246
+ const arrived = await this.mailStore.changes.wait(waiting, { topics: ['message', 'claim'], addresses: [caller], timeoutMs: seconds * 1000 });
4247
+ if (this.stopping) throw Object.assign(new Error('the daemon is stopping; the wait ended without claiming mail'), { code: 503 });
4242
4248
  if (!arrived) return { code: 6, timeout: true, text: `No mail within ${seconds} seconds; nothing is lost.` };
4243
4249
  }
4244
4250
  // everything unread is handed over, not only the awaited sender's: a message not shown now would stay silent
4245
- return this.#claimInbox(caller, { all: false, last: null, pid: Number(params.claim?.pid) || process.pid, via: 'wait' });
4251
+ return { ...await this.#claimInbox(caller, { all: false, last: null, pid: Number(params.claim?.pid) || process.pid, via: 'wait' }), cursor: this.mailStore.changes.cursor() };
4246
4252
  }
4247
4253
  // harness serve's `sessions.inbox` (step 1), asked here by serve: an operator's own mailbox (`from_name`) has its
4248
4254
  // unread mail claimed and acknowledged, so a second read does not return it; any other address (`address`) is only
@@ -4402,6 +4408,12 @@ export class TeamsMachine extends MachineCore {
4402
4408
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail storage diagnostics are read only by this machine\'s own processes'), { code: 403 });
4403
4409
  return this.mailStore.statistics();
4404
4410
  }
4411
+ case 'harness.v1.mail.changes': {
4412
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail changes are read by this machine\'s own processes'), { code: 403 });
4413
+ const addresses = params.addresses ?? [];
4414
+ if (!Array.isArray(addresses) || addresses.length > 256 || addresses.some((address) => !parseAddress(address))) throw Object.assign(new Error('changes takes at most 256 mailbox addresses'), { code: 400 });
4415
+ return this.mailStore.changes.read(params.after, { topics: ['message', 'delivery', 'claim', 'subscription'], addresses });
4416
+ }
4405
4417
  // Who a local client is, from its claim (live-sessions.mjs callerOf): what a command acting on another machine sends
4406
4418
  // there as its caller, resolved here, where the map that holds it is. Local only; never the app door's.
4407
4419
  // The pane this daemon launched that a local process runs in, and who started that pane: what a process with no