@volter/supercode-teams 0.3.185 → 0.3.188

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
@@ -84,7 +84,7 @@ import { WorkspaceContexts, fromContext } from '@volter/teams/context';
84
84
  import { TeamsClient } from '@volter/teams/http-client';
85
85
  import { supercodeHome } from '@volter/teams/home';
86
86
  import { readMailHistory, machineName } from './mail-history.mjs';
87
- import { randomBytes, createHash } from 'node:crypto';
87
+ import { randomBytes, createHash, randomUUID } from 'node:crypto';
88
88
  import { WebSocket } from 'ws';
89
89
  import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
90
90
  // The ConPTY host is read from the namespace, where a terminal package too old to have it gives undefined rather
@@ -137,6 +137,18 @@ export function claudeUsageLimitWait(screen) {
137
137
  return /usage limit to reset/i.test(status) && /rate-limit-options/i.test(status) && !/esc to interrupt/i.test(status);
138
138
  }
139
139
 
140
+ /** Claude's /btw panel, from its native dialog receipt and live panel footer.
141
+ * Permission prompts, settings panels and an unknown layout do not qualify. */
142
+ export function claudeSideQuestionPanel(screen, activity) {
143
+ if (activity?.harness !== 'claude-code' || activity.evidence?.source !== 'claude_registry'
144
+ || activity.evidence?.waiting_for !== 'dialog open') return false;
145
+ const rows = String(screen).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').split('\n').map(line => line.trim()).filter(Boolean);
146
+ const footer = rows.slice(-3).join('\n');
147
+ return /^\/btw(?:\s|$)/mu.test(rows.slice(-40).join('\n'))
148
+ && /\b(?:esc|escape)(?:\s+to)?\s+close\b/iu.test(footer)
149
+ && composerHoldsDraft('claude-code', screen) === null;
150
+ }
151
+
140
152
  /** The settle floor for a pane whose kind has no composer pattern: the screen
141
153
  * must have stopped changing for this long before the pane counts as ready. */
142
154
  const INITIAL_INPUT_SETTLE_MS = 1_000;
@@ -551,6 +563,7 @@ export class TeamsMachine extends MachineCore {
551
563
  // Set by a service-run connector: how `machine.restart` ends this daemon so its service manager starts it again.
552
564
  this.restartHandler = null;
553
565
  this.launches = new Launches(this.p.launches);
566
+ this.boardOwnersPath = join(this.home, 'board-owners.json');
554
567
  // The release this daemon runs, in its machine record (`supercode teams machine status`): its Teams package and its
555
568
  // native. A daemon an upgrade did not restart runs the release it started with, whatever is installed, and a
556
569
  // client a release behind or ahead of it is refused, so status says which it is (2026-10-06: version skew between
@@ -559,7 +572,7 @@ export class TeamsMachine extends MachineCore {
559
572
  try {
560
573
  const record = readJson(this.p.machine, null);
561
574
  if (record?.pid !== process.pid) return;
562
- writeJson(this.p.machine, { ...record, release: this.#release() });
575
+ writeJson(this.p.machine, { ...record, release: this.#release(), mail_ready: false });
563
576
  } catch { /* its record says no release */ }
564
577
  });
565
578
  // the panes' stable porthole hook and the machine's stable supercode brought up to this release's at start, under
@@ -714,9 +727,10 @@ export class TeamsMachine extends MachineCore {
714
727
  try {
715
728
  requireSessionIndexRuntime();
716
729
  this.bornAt ??= Date.now();
717
- this.mailStore = new SqliteMailStore({ mailRoot: join(supercodeHome(this.env), 'mail'), machine: machineName(this.env), log: (line) => this.emit('records-log', line) });
730
+ this.mailStore = new SqliteMailStore({ mailRoot: join(supercodeHome(this.env), 'mail'), machine: machineName(this.env), committed: this.env.SUPERCODE_MAIL_COMMITTED === '1', startupRecovery: this.env.SUPERCODE_MAIL_STARTUP_RECOVERY === '1', log: (line) => this.emit('records-log', line) });
718
731
  this.mailStore.acquire();
719
732
  await this.mailStore.start();
733
+ await this.#restoreBoardOwners();
720
734
  this.sessionIndex = new SessionIndexOwner({ database: this.mailStore.database, env: this.env, log: (line) => this.emit('records-log', line) });
721
735
  this.indexViews = new SessionIndexViews({ owner: this.sessionIndex,
722
736
  enrichLive: (query, rows) => projectLiveDescriptors(query, rows, { owner: this.sessionIndex, live: this.live, mail: this.mail }),
@@ -738,18 +752,29 @@ export class TeamsMachine extends MachineCore {
738
752
 
739
753
  async afterListen() {
740
754
  try {
755
+ // First activation must prove its native before any delivery. Later
756
+ // restarts keep serving the committed mail while native indexing starts.
757
+ if (this.env.SUPERCODE_UPGRADE_ACTIVATING === '1') await this.#serveRequest('harness.v1.harnesses.list', {}, 4000);
741
758
  this.#startLive();
742
759
  await this.#startMail();
743
- this.mailReady = true;
744
- } catch (error) {
745
- await this.stop();
746
- throw error;
747
- }
748
760
  // Web apps on this machine reach it through the app door when the person has turned it on (app-door.mjs).
749
761
  this.appDoor = serveAppDoor({ home: this.home, open: () => { const [page, daemon] = inProcessPair(); void this.acceptLocal(daemon, { appDoor: true }); return page; }, log: (row) => this.log.append({ principal: 'app-door', cap: 'app-door', target: row.origin ?? '*', outcome: row.outcome, detail: row.detail }) });
750
762
  this.#startHealth();
751
763
  this.#startRecords();
752
764
  if(Object.keys(this.inputRepairs).length)this.#watchInputRepairs();
765
+ const readyRecord = readJson(this.p.machine, null);
766
+ if (readyRecord?.pid !== process.pid) throw new Error('daemon startup lost its machine record before mail readiness');
767
+ writeJson(this.p.machine, { ...readyRecord, mail_ready: true, mail_recovery: this.mailStore.deferred === true,
768
+ ...(this.env.SUPERCODE_UPGRADE_SUPERVISOR ? { upgrade_supervisor: Number(this.env.SUPERCODE_UPGRADE_SUPERVISOR) } : {}) });
769
+ if (!this.mailStore.deferred && this.env.SUPERCODE_UPGRADE_READY_FILE) writeJson(this.env.SUPERCODE_UPGRADE_READY_FILE, { pid: process.pid,
770
+ supervisor: Number(this.env.SUPERCODE_UPGRADE_SUPERVISOR),
771
+ release: JSON.parse(this.env.SUPERCODE_UPGRADE_RELEASE_RECEIPT), at_ms: Date.now() });
772
+ this.mailReady = true;
773
+ if (!this.mailStore.deferred) this.activateMail();
774
+ } catch (error) {
775
+ await this.stop();
776
+ throw error;
777
+ }
753
778
  }
754
779
 
755
780
  // ---- this machine's records (order 3 PR 3, machine-records.mjs) -------------
@@ -845,8 +870,6 @@ export class TeamsMachine extends MachineCore {
845
870
  async #startMail() {
846
871
  const mailRoot = join(supercodeHome(this.env), 'mail');
847
872
  // the Messages API a Claude relay talks to (step 4a: the daemon's, in process; the native watch served it)
848
- this.relayEndpoint = serveRelayEndpoint(this.mailStore, { log: (line) => this.emit('mail-log', line) })
849
- .catch((error) => { this.emit('mail-log', `relay endpoint: not served (${error.message}); a Claude session cannot be sent mail until the daemon starts again`); return null; });
850
873
  // the session index's rows by address (its descriptors, as the records' index subscription hands them over)
851
874
  this.sessionDescriptors = new Map();
852
875
  const live = this.live;
@@ -886,6 +909,30 @@ export class TeamsMachine extends MachineCore {
886
909
  const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
887
910
  const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
888
911
  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' };
912
+ // A lifecycle wake belongs to the board's owned session, not the /btw
913
+ // composer. Dismiss only that identified panel; never answer a question,
914
+ // approve a permission, clear a draft or interrupt the underlying turn.
915
+ const boardWake = messageIds?.length && messageIds.every(id => {
916
+ const stored = this.mailStore.findIn(address, id);
917
+ const envelope = stored ? this.mailStore.body(stored) : null;
918
+ return envelope?.kind === 'notice' && parseAddress(envelope.from)?.harness === 'board';
919
+ });
920
+ if (boardWake && claudeSideQuestionPanel(await this.host.capture(session.id, { lines: 200, preserveEscapes: true }), native)) {
921
+ const owner = randomUUID();
922
+ if (await this.host.acquireInput?.(session.id, owner)) {
923
+ try {
924
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
925
+ const latest = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
926
+ const current = latest?.activities?.find(row => row.pid === session.rootPid)?.activity;
927
+ const screen = await this.host.capture(session.id, { lines: 200, preserveEscapes: true });
928
+ if (!this.stopping && current?.session_id === expected.session && claudeSideQuestionPanel(screen, current)) {
929
+ await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', inputOwner: owner, deadlineMs: Date.now() + 5_000 });
930
+ this.log.append({ principal: null, cap: 'control', target: `pane:${pane}`, outcome: 'board-side-question-dismissed', detail: JSON.stringify({ address, message_ids: messageIds }) });
931
+ await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
932
+ }
933
+ } finally { await this.host.releaseInput?.(session.id, owner); }
934
+ }
935
+ }
889
936
  const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
890
937
  if (result.refused) throw new Error(result.refused.reason);
891
938
  return result.waits ?? result;
@@ -911,27 +958,23 @@ export class TeamsMachine extends MachineCore {
911
958
  // a relay's inbound mail its hook kept while this daemon did not answer (the front door's spool): filed now, each
912
959
  // minute, and before each relay's inbound call
913
960
  this.hookSpool = join(supercodeHome(this.env), 'hook-spool', 'relay-inbound');
914
- this.mailHooks.drainSpool(this.hookSpool);
915
- this.mailHooks.drainPoolReplies();
916
- this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
917
961
  // the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
918
- this.carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
962
+ const carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
919
963
  handOver: (address, id) => this.mailSend.handOver(address, id),
920
964
  typeTurn, typeUserTurns: (address, pane) => this.mailSend.typeUserTurns(address, pane), paneOf: (session) => this.mail.paneOf(session),
921
965
  askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
922
- log: (line) => this.emit('mail-log', line) }).start();
966
+ log: (line) => this.emit('mail-log', line) });
923
967
  // idle notices and final replies (step 4c): each subscribed session's activity followed through harness serve
924
- this.idleNotices = new IdleNotices({ machine, store: this.mailStore, live,
968
+ const idleNotices = new IdleNotices({ machine, store: this.mailStore, live,
925
969
  serveRequest: (method, params, ms) => this.#serveRequest(method, params, ms),
926
970
  locatorOf: (address) => this.sessionDescriptors?.get(address)?.locator ?? null,
927
- log: (line) => this.emit('mail-log', line) }).start();
971
+ log: (line) => this.emit('mail-log', line) });
928
972
  // native Codex questions as mail (step 4c): the poll of each launched Codex pane's own app-server, in process
929
- this.nativeQuestions.start();
930
973
  // an agent's upkeep (step 4d): folder sessions become threads, typed lines reach their CC, idle launched threads end
931
- this.agentUpkeep = new AgentUpkeep({ machine, mailRoot, store: this.mailStore, agents: this.agents, live,
974
+ const agentUpkeep = new AgentUpkeep({ machine, mailRoot, store: this.mailStore, agents: this.agents, live,
932
975
  claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
933
976
  lastMessageAt: (address) => this.sessionDescriptors?.get(address)?.updated_at_ms ?? null,
934
- program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) }).start();
977
+ program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) });
935
978
  // User turns share the persisted delivery schedule; only their own raw-input door types them.
936
979
  // an agent's verbs (3b-2): its threads, a delegation, its record
937
980
  this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
@@ -941,9 +984,18 @@ export class TeamsMachine extends MachineCore {
941
984
  // the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
942
985
  // runs under another, kept from the map's changes (at most once a second)
943
986
  let naming = null;
944
- void this.relays.sweepAtStart().catch((error) => this.emit('mail-log', `relay pool: predecessors retained (${error.message})`));
987
+ this.activateMail = () => {
988
+ this.relayEndpoint = serveRelayEndpoint(this.mailStore, { log: line => this.emit('mail-log', line) })
989
+ .catch(error => { this.emit('mail-log', `relay endpoint: not served (${error.message})`); return null; });
990
+ this.mailHooks.drainSpool(this.hookSpool);
991
+ this.mailHooks.drainPoolReplies();
992
+ this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
993
+ this.carrier = carrier.start(); this.idleNotices = idleNotices.start();
994
+ this.nativeQuestions.start(); this.agentUpkeep = agentUpkeep.start();
995
+ void this.relays.sweepAtStart().catch(error => this.emit('mail-log', `relay pool: predecessors retained (${error.message})`));
996
+ };
945
997
  live.on('changed', () => {
946
- if (this.stopping) return;
998
+ if (this.stopping || !this.mailReady || this.mailStore.deferred) return;
947
999
  if (naming) return;
948
1000
  naming = setTimeout(() => { naming = null; try { this.mailSend.rememberNames(live.sessions()); } catch { /* kept at the next change */ } }, 1000);
949
1001
  naming.unref?.();
@@ -1014,6 +1066,15 @@ export class TeamsMachine extends MachineCore {
1014
1066
  try { activities = (await this.#serveRequest('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }, 30_000))?.activities ?? []; }
1015
1067
  catch (error) { sessions_unread = String(error?.message ?? error).slice(0, 200); }
1016
1068
  }
1069
+ const launches = new Map(this.launches.all().map(launch => [launch.pane, launch]));
1070
+ for (const pane of live) {
1071
+ const launch = launches.get(pane.pane), activity = activities.find(row => row.pid === pane.pid)?.activity;
1072
+ // The daemon resolved the starter at launch; the native receipt and
1073
+ // current process must name the same child. A pane title or argv is
1074
+ // never a delegation receipt.
1075
+ if (launch?.status === 'ready' && launch.native_session && launch.native_session === activity?.session_id && launch.kind === activity.harness)
1076
+ pane.starter_session = launch.by ?? null;
1077
+ }
1017
1078
  this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid,
1018
1079
  activity: sessions_unread ? null : activities.find(row => row.pid === pane.pid)?.activity ?? null })), { current: sessions_unread == null });
1019
1080
  // the sessions under the panes unread: the panes are read again on the section's backoff until they are (MR4)
@@ -1901,6 +1962,7 @@ export class TeamsMachine extends MachineCore {
1901
1962
  // receiver named rather than addressed needs `mail:*`.
1902
1963
  const { admitted, principal } = entry;
1903
1964
  const request = requestOverride??stream.params?.request;
1965
+ if (this.mailStore.deferred && !['list', 'show', 'status', 'history'].includes(request?.op)) return refuse('mail migration is deferred; this daemon serves reads from retained originals while damaged operational records await repair');
1904
1966
  if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
1905
1967
  if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
1906
1968
  const to = typeof request?.to === 'string' ? request.to : '*';
@@ -2060,7 +2122,7 @@ export class TeamsMachine extends MachineCore {
2060
2122
  /** The home and board a board name (an address's last part) names among the homes registered with this daemon, by the
2061
2123
  * one rule the board names itself with (@volter/teams/board-name). */
2062
2124
  #boardKept(name) {
2063
- for (const root of Object.keys(this.boardEvents?.state?.boards ?? {})) {
2125
+ for (const root of new Set([...Object.keys(this.boardEvents?.state?.boards ?? {}), ...(this.boardOwners?.keys() ?? [])])) {
2064
2126
  if (boardName(boardLabel(root, 'default')) === name) return { root, board: 'default' };
2065
2127
  let named = [];
2066
2128
  try { named = readdirSync(join(root, 'kanban', 'boards')); } catch { /* no named boards */ }
@@ -2303,7 +2365,7 @@ export class TeamsMachine extends MachineCore {
2303
2365
  const answer = spawnSync(this.supercodeBin, ['--version'], { env: this.env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
2304
2366
  native = answer.status === 0 ? answer.stdout.trim() || null : null;
2305
2367
  } catch { /* unread */ }
2306
- return { teams, native };
2368
+ return { teams, native, supercode: this.env.SUPERCODE_RELEASE_VERSION ?? null };
2307
2369
  }
2308
2370
 
2309
2371
  #portholeHook() {
@@ -2562,6 +2624,23 @@ export class TeamsMachine extends MachineCore {
2562
2624
  return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
2563
2625
  }
2564
2626
 
2627
+ /** A restart retains the homes a verified owner registered. A saved PID is
2628
+ * only a candidate: the kernel must still show it holding that home's lock.
2629
+ * Caller admission separately proves the live connection's process. */
2630
+ async #restoreBoardOwners() {
2631
+ this.boardOwners = new Map();
2632
+ const saved = readJson(this.boardOwnersPath, { owners: {} });
2633
+ for (const [root, pid] of Object.entries(saved?.owners ?? {})) {
2634
+ if (isAbsolute(root) && Number.isSafeInteger(pid) && pid > 1 && await this.#holdsBoardLock(root, pid))
2635
+ this.boardOwners.set(root, pid);
2636
+ }
2637
+ this.#saveBoardOwners();
2638
+ }
2639
+
2640
+ #saveBoardOwners() {
2641
+ writeJson(this.boardOwnersPath, { version: 1, owners: Object.fromEntries(this.boardOwners ?? []) });
2642
+ }
2643
+
2565
2644
  /** 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
2566
2645
  * (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
2567
2646
  * reaches this one only through its link, so a return address is never an identity. */
@@ -2575,7 +2654,7 @@ export class TeamsMachine extends MachineCore {
2575
2654
  async #runFiling(request) {
2576
2655
  if (!this.mailSend) return { code: 1, text: 'this machine\'s daemon has not started its mail yet' };
2577
2656
  return this.mailSend.file({ address: request.from, name: request.from_name ?? '' }, request.to, request.body ?? '', {
2578
- subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false,
2657
+ subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false, notice: request.notice === true,
2579
2658
  sender_identity: request.sender_identity && typeof request.sender_identity === 'object' ? request.sender_identity : null, typed: request.typed === true,
2580
2659
  });
2581
2660
  }
@@ -3633,6 +3712,9 @@ export class TeamsMachine extends MachineCore {
3633
3712
  entry.admitted = fresh;
3634
3713
  }
3635
3714
  if (!this.mailReady || this.stopping) throw Object.assign(new Error(this.stopping ? 'this machine daemon is stopping' : 'this machine daemon is preparing its mail database; retry once migration completes'), { code: 503 });
3715
+ if (this.mailStore.deferred && ((method.startsWith('harness.v1.mail.') && !['list', 'show', 'statistics', 'names', 'tools', 'changes'].some(name => method === `harness.v1.mail.${name}`)) || method === 'harness.v1.agent.declare')) {
3716
+ throw Object.assign(new Error('mail migration is deferred; public mail reads remain available, delivery and acknowledgement await operational-state repair'), { code: 503 });
3717
+ }
3636
3718
  const { admitted, principal } = entry;
3637
3719
  const operator = admitted.class === 'operator';
3638
3720
  const need = (cap, target) => {
@@ -3842,6 +3924,7 @@ export class TeamsMachine extends MachineCore {
3842
3924
  // never needing a person at it. Only a daemon its service manager brings back may go: one run by hand would not return.
3843
3925
  if (!operator) { this.log.append({ principal, cap: method, target: '*', outcome: 'refused' }); throw Object.assign(new Error('only an operator restarts the machine daemon'), { code: 403 }); }
3844
3926
  if (!this.restartHandler) throw Object.assign(new Error('this daemon runs without a service manager, so nothing would start it again; restart it where it runs'), { code: 'unsupported' });
3927
+ await this.restartAdmission?.();
3845
3928
  act(method, '*', 'ok', params.reason ? String(params.reason).slice(0, 200) : null);
3846
3929
  // The answer goes out first; the service manager starts the next daemon.
3847
3930
  setTimeout(() => this.restartHandler(params.reason ? String(params.reason) : null), 500).unref?.();
@@ -4482,7 +4565,7 @@ export class TeamsMachine extends MachineCore {
4482
4565
  for (const key of ['from', 'to', 'body']) if (typeof params[key] !== 'string' || !params[key]) throw Object.assign(new Error(`a filing names its ${key}`), { code: 400 });
4483
4566
  const sender_identity = await this.#observeSender(params.from, params.from_name ?? null, typeof params.context === 'string' && params.context ? params.context : null);
4484
4567
  const answer = await this.#runFiling({ from: params.from, from_name: params.from_name ?? '', to: params.to, body: params.body, id: params.id ?? null,
4485
- wake: params.wake !== false, subject: params.subject ?? null, sender_identity });
4568
+ wake: params.wake !== false, subject: params.subject ?? null, notice: params.notice === true, sender_identity });
4486
4569
  act('mail.file', params.to, answer?.code === 0 ? 'ok' : 'failed');
4487
4570
  return answer;
4488
4571
  }
@@ -4795,21 +4878,26 @@ export class TeamsMachine extends MachineCore {
4795
4878
  case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
4796
4879
  // A board home this machine keeps, published to its team by its connector (board-events.mjs).
4797
4880
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
4798
- if (!this.boardEvents) throw Object.assign(new Error('this daemon serves no team: a board is published by a connected machine (`supercode teams connect`)'), { code: 409 });
4799
- if (method === 'harness.v1.boards.list') return this.boardEvents.list();
4881
+ if (method === 'harness.v1.boards.list') return this.boardEvents?.list() ?? [];
4800
4882
  if (method === 'harness.v1.boards.unregister') {
4801
4883
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4802
- return this.boardEvents.unregister(params.root);
4884
+ this.boardOwners?.delete(resolve(params.root));
4885
+ try { this.boardOwners?.delete(realpathSync(params.root)); } catch { /* already removed */ }
4886
+ this.#saveBoardOwners();
4887
+ return this.boardEvents?.unregister(params.root) ?? { root: resolve(params.root), published: false };
4803
4888
  }
4804
4889
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4805
- const registered = this.boardEvents.register(params.root);
4890
+ const registered = this.boardEvents?.register(params.root) ?? { root: resolve(params.root), published: false };
4806
4891
  // The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
4807
4892
  // (#callerOf). The owner is the process holding the home's board lock (board.lock, through its lock holder), as
4808
4893
  // the kernel says: the registering client names its pid (its claim), and only a pid that holds the lock is
4809
4894
  // taken. A registration from any other process publishes the home and grants nothing, and never replaces the
4810
4895
  // owner's.
4811
4896
  const pid = entry.principal === 'local' && !entry.appDoor ? Number(params.claim?.pid) : NaN;
4812
- if (Number.isInteger(pid) && await this.#holdsBoardLock(registered.root, pid)) (this.boardOwners ??= new Map()).set(registered.root, pid);
4897
+ if (Number.isInteger(pid) && (!entry.socket || await peerIs(entry.socket, pid)) && await this.#holdsBoardLock(registered.root, pid)) {
4898
+ (this.boardOwners ??= new Map()).set(registered.root, pid);
4899
+ this.#saveBoardOwners();
4900
+ }
4813
4901
  act('boards.register', registered.root);
4814
4902
  return registered;
4815
4903
  }
@@ -48,7 +48,7 @@ async function* logRecords(stream, start) {
48
48
  else if (fragments.length) yield { bytes: assembled(), offset: position };
49
49
  }
50
50
 
51
- export async function importMaildir(database, root, { log = () => {}, legacyRoot = root } = {}) {
51
+ export async function importMaildir(database, root, { log = () => {}, legacyRoot = root, deferOperational = false } = {}) {
52
52
  const began = performance.now();
53
53
  const work = { source_reads: 0, source_bytes: 0, committed_batches: 0, max_queued_operations: 0, resumed_directories: 0, resumed_entries: 0, resumed_log_bytes: 0, elapsed_ms: 0 };
54
54
  const complete = database.metadata('maildir_import');
@@ -85,13 +85,13 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
85
85
  if (pending.length >= 256) flush();
86
86
  };
87
87
  const errors = database.statement('INSERT INTO mail_import_errors(source, mailbox, detail, critical) VALUES (?, ?, ?, ?) ON CONFLICT(source) DO UPDATE SET mailbox = excluded.mailbox, detail = excluded.detail, critical = excluded.critical');
88
- const failed = (path, error, mailbox = null) => {
88
+ const failed = (path, error, mailbox = null, { diagnostic = false } = {}) => {
89
89
  const source = relative(root, path);
90
90
  const parts = source.split(/[\\/]/);
91
- const critical = ['agents', 'threads', 'native-questions', 'native-launches', 'relays', 'machines', 'sent'].includes(parts[0])
91
+ const critical = !diagnostic && (['agents', 'threads', 'native-questions', 'native-launches', 'relays', 'machines'].includes(parts[0])
92
92
  || ['claims', 'subscriptions', 'wake'].includes(parts[1])
93
93
  || parts[0] === 'names.json' || parts[0] === 'relay-endpoint.json'
94
- || (parts[0] === 'terminal-delivery' && parts.at(-1) === 'queue.json');
94
+ || (parts[0] === 'terminal-delivery' && parts.at(-1) === 'queue.json'));
95
95
  queue(() => errors.run(source, mailbox, String(error.message ?? error), critical ? 1 : 0));
96
96
  result.skipped++;
97
97
  log(`mail import: retained unreadable source ${source}: ${error.message ?? error}`);
@@ -194,7 +194,7 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
194
194
  if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
195
195
  const path = join(dir, 'claims', entry.name), id = basename(entry.name, '.json');
196
196
  const claim = await json(path, address);
197
- if (claim && !present(id)) { failed(path, 'claim has no imported message', address); continue; }
197
+ if (claim && !present(id)) { failed(path, 'claim has no imported message', address, { diagnostic: true }); continue; }
198
198
  if (claim) queue(() => database.statement('INSERT INTO claims(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO NOTHING').run(address, id, JSON.stringify(claim)));
199
199
  }
200
200
  for await (const entry of entries(join(dir, 'subscriptions'))) {
@@ -384,11 +384,12 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
384
384
  result.records = database.statement('SELECT count(*) AS count FROM mail_records').get().count;
385
385
  result.skipped = database.statement('SELECT count(*) AS count FROM mail_import_errors').get().count;
386
386
  work.elapsed_ms = Math.round(performance.now() - began);
387
+ database.statement("UPDATE mail_import_errors SET critical = 0 WHERE source LIKE 'sent/%/log.jsonl' OR detail = 'claim has no imported message'").run();
387
388
  const blocked = database.statement('SELECT source FROM mail_import_errors WHERE critical = 1 ORDER BY source').all();
388
- if (blocked.length) {
389
+ if (blocked.length && !deferOperational) {
389
390
  throw Object.assign(new Error(`mail migration cannot activate with ${blocked.length} unreadable operational records (${blocked.slice(0, 3).map(({ source }) => source).join(', ')}); original files are retained for repair`), { code: 'mail_import_incomplete' });
390
391
  }
391
- result.complete = true;
392
+ result.complete = blocked.length === 0;
392
393
  database.setMetadata('maildir_import', result);
393
394
  log(`mail import complete: ${result.messages} envelopes, ${result.records} operational records, ${result.skipped} retained unreadable sources; ${work.source_reads} source reads, ${work.source_bytes} bytes, ${work.committed_batches} batches in ${work.elapsed_ms} ms`);
394
395
  return result;
@@ -0,0 +1,36 @@
1
+ // When there is no earlier release to serve, retain the source stores and
2
+ // answer reads from a disposable private view. Never activate damaged routing
3
+ // records or run delivery against an incomplete migration.
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { mkdir, lstat, rm } from 'node:fs/promises';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { MailDatabase } from './sqlite.mjs';
8
+ import { importMaildir } from './import-maildir.mjs';
9
+
10
+ export async function recoveryMailView(root, { log = () => {} } = {}) {
11
+ const home = dirname(root), directory = join(home, 'teams', 'service', 'mail-recovery');
12
+ await mkdir(directory, { recursive: true, mode: 0o700 });
13
+ const path = join(directory, 'mail.sqlite');
14
+ // The caller holds this home's mail owner lease. This is its own disposable
15
+ // view, never the source SQLite database or an archived original.
16
+ for (const suffix of ['', '-wal', '-shm']) await rm(path + suffix, { force: true });
17
+ const live = join(home, 'mail.sqlite');
18
+ try {
19
+ await lstat(live);
20
+ const reader = new DatabaseSync(live, { readOnly: true });
21
+ try { reader.prepare('VACUUM INTO ?').run(path); } finally { reader.close(); }
22
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ const database = new MailDatabase(path);
24
+ try {
25
+ const retired = database.metadata('maildir_retirement');
26
+ let input = root;
27
+ try { await lstat(input); }
28
+ catch (error) {
29
+ if (error.code !== 'ENOENT') throw error;
30
+ if (retired?.archive && dirname(resolve(retired.archive)) === resolve(home, 'mail-retired')) input = retired.archive;
31
+ }
32
+ await importMaildir(database, input, { legacyRoot: root, deferOperational: true, log });
33
+ log('mail migration is deferred; original stores retained, public mail reads use a private recovery view, delivery is unavailable');
34
+ return database;
35
+ } catch (error) { database.close(); throw error; }
36
+ }
@@ -1,10 +1,10 @@
1
- // Migration owns a private snapshot before reading it. A successful import
2
- // retires only the exact bytes whose database writes completed. Unknown and
3
- // unreadable sources remain as named diagnostics, never as a read fallback.
1
+ // Import and validate while the legacy store stays at its original path. Only
2
+ // a complete, unchanged import can retire it. Keep originals for recovery;
3
+ // retiring mail is never permission to delete another release's readable data.
4
4
  import { createReadStream } from 'node:fs';
5
- import { lstat, mkdir, readdir, rename, rmdir, unlink } from 'node:fs/promises';
5
+ import { lstat, mkdir, rename } from 'node:fs/promises';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
- import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
8
8
  import { importMaildir } from './import-maildir.mjs';
9
9
  import { requireLegacyMaildirReady } from './migration-readiness.mjs';
10
10
 
@@ -12,95 +12,84 @@ const stat = async (path) => {
12
12
  try { return await lstat(path); }
13
13
  catch (error) { if (error.code === 'ENOENT') return null; throw error; }
14
14
  };
15
+
16
+ export async function verifyImportedMaildir(database, root) {
17
+ for (const { key, value } of database.statement("SELECT key, value FROM mail_metadata WHERE key LIKE 'maildir_directory:%'").all()) {
18
+ const saved = JSON.parse(value), path = resolve(root, key.slice('maildir_directory:'.length));
19
+ if (path !== root && !path.startsWith(root + sep)) throw new Error('mail import directory receipt is outside its source');
20
+ const current = await stat(path);
21
+ const stamp = current && { device: current.dev, inode: current.ino, mtime: current.mtimeMs, ctime: current.ctimeMs };
22
+ if (JSON.stringify(saved.stamp) !== JSON.stringify(stamp)) throw new Error(`mail source directory changed before retirement: ${path}`);
23
+ }
24
+ const sources = database.statement('SELECT source, sha256 FROM mail_import_sources WHERE source > ? ORDER BY source LIMIT 256');
25
+ let cursor = '';
26
+ for (;;) {
27
+ const batch = sources.all(cursor);
28
+ if (!batch.length) return;
29
+ for (const row of batch) {
30
+ const path = resolve(root, row.source);
31
+ if (isAbsolute(row.source) || !path.startsWith(root + sep)) throw new Error('an imported source is outside its Maildir');
32
+ let parent = dirname(path);
33
+ while (parent !== root) {
34
+ if (!(await stat(parent))?.isDirectory()) throw new Error(`mail source directory changed before retirement: ${row.source}`);
35
+ parent = dirname(parent);
36
+ }
37
+ if (!(await stat(path))?.isFile()) throw new Error(`mail source changed before retirement: ${row.source}`);
38
+ const hash = createHash('sha256');
39
+ for await (const bytes of createReadStream(path)) hash.update(bytes);
40
+ if (hash.digest('hex') !== row.sha256) throw new Error(`mail source bytes changed before retirement: ${row.source}`);
41
+ }
42
+ cursor = batch.at(-1).source;
43
+ }
44
+ }
45
+
15
46
  /** Called only while this daemon holds the mail owner lease. */
16
- export async function migrateMaildir(database, root, { log = () => {} } = {}) {
47
+ export async function migrateMaildir(database, root, { log = () => {}, committed = false } = {}) {
17
48
  root = resolve(root);
18
49
  let retirement = database.metadata('maildir_retirement');
19
50
  let source = await stat(root);
20
51
  if (!retirement) {
21
52
  if (source && !source.isDirectory()) throw new Error(`mail migration requires a regular directory at ${root}`);
22
53
  if (source) await requireLegacyMaildirReady(root);
23
- retirement = { source: root, archive: source ? join(dirname(root), 'mail-retired', randomUUID()) : null, started_at_ms: Date.now(), complete: false };
54
+ retirement = { version: 2, source: root, archive: source ? join(dirname(root), 'mail-retired', randomUUID()) : null, started_at_ms: Date.now(), complete: false };
24
55
  database.setMetadata('maildir_retirement', retirement);
25
56
  }
26
57
  if (retirement.source !== root) throw new Error('the mail migration source differs from its stored receipt');
27
58
  if (retirement.complete) {
28
- if (source) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
59
+ if (source && !committed) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
60
+ if (source) log(`committed SQLite mailbox remains serving; recreated legacy records at ${root} are retained for explicit recovery`);
29
61
  return database.metadata('maildir_import');
30
62
  }
31
63
  if (!retirement.archive) {
32
64
  if (source) throw new Error(`a legacy Maildir appeared after this home's SQLite owner started: ${root}; it is retained, and migration is refused`);
33
65
  const imported = await importMaildir(database, root, { log });
34
- database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), retained_sources: 0 });
66
+ database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), originals_retained: true });
35
67
  return imported;
36
68
  }
37
69
  const archiveRoot = resolve(dirname(root), 'mail-retired');
38
70
  const archive = resolve(retirement.archive);
39
71
  if (dirname(archive) !== archiveRoot) throw new Error('the stored migration snapshot is outside this home');
40
72
  let snapshot = await stat(archive);
41
- if (source) {
42
- if (snapshot || retirement.complete) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
43
- if (!source.isDirectory()) throw new Error('mail migration source is no longer a regular directory');
73
+ if (source && snapshot) throw new Error(`the retired Maildir was recreated at ${root}; both stores are retained for explicit recovery`);
74
+ if (!source && !snapshot) throw new Error('the incomplete mail migration source is missing; originals cannot be reconstructed');
75
+ const input = snapshot ? archive : root;
76
+ if (!(snapshot ?? source).isDirectory()) throw new Error('the migration source is not a regular directory');
77
+ if (snapshot && retirement.inode != null && (retirement.inode !== snapshot.ino || retirement.device !== snapshot.dev)) throw new Error('the migration snapshot differs from its ownership receipt');
78
+ // Version-one receipts may already have moved the source before validation.
79
+ // Resume that snapshot, retaining it even when an operational input refuses.
80
+ const imported = await importMaildir(database, input, { log, legacyRoot: root });
81
+ if (snapshot && await stat(root)) throw new Error(`a legacy writer recreated ${root} during migration; both stores are retained`);
82
+ if (!snapshot) {
44
83
  await requireLegacyMaildirReady(root);
84
+ await verifyImportedMaildir(database, root);
85
+ const current = await stat(root);
86
+ if (current?.ino !== source.ino || current?.dev !== source.dev) throw new Error('mail root changed before retirement');
45
87
  await mkdir(archiveRoot, { recursive: true, mode: 0o700 });
46
88
  await rename(root, archive);
47
89
  snapshot = await stat(archive);
48
90
  }
49
- if (!snapshot) {
50
- if (!retirement.complete) throw new Error('the incomplete mail migration snapshot is missing; originals cannot be reconstructed');
51
- return database.metadata('maildir_import');
52
- }
53
- if (!snapshot.isDirectory()) throw new Error('the migration snapshot is not a regular directory');
54
- if (retirement.inode != null && (retirement.inode !== snapshot.ino || retirement.device !== snapshot.dev)) throw new Error('the migration snapshot differs from its ownership receipt');
55
- retirement = { ...retirement, inode: snapshot.ino, device: snapshot.dev };
56
- database.setMetadata('maildir_retirement', retirement);
57
- const imported = await importMaildir(database, archive, { log, legacyRoot: root });
58
- // No ordinary operation becomes ready if an old entrypoint created a second
59
- // store during import. It is never silently reimported over the first one.
60
- if (await stat(root)) throw new Error(`a legacy writer recreated ${root} during migration; originals are retained and mail is not activated`);
61
- const sources = database.statement(`SELECT source, sha256 FROM mail_import_sources s WHERE purged = 0 AND source > ?
62
- AND NOT EXISTS (SELECT 1 FROM mail_import_errors e WHERE e.source = s.source) ORDER BY source LIMIT 256`);
63
- const purged = database.statement('UPDATE mail_import_sources SET purged = 1 WHERE source = ?');
64
- let removed = 0;
65
- let cursor = '';
66
- for (;;) {
67
- const batch = sources.all(cursor);
68
- if (!batch.length) break;
69
- const done = [];
70
- for (const row of batch) {
71
- const path = resolve(archive, row.source);
72
- if (isAbsolute(row.source) || !path.startsWith(archive + sep)) throw new Error('an imported source is outside its migration snapshot');
73
- let parent = dirname(path), regular = true;
74
- while (parent !== archive) {
75
- if (!(await stat(parent))?.isDirectory()) { regular = false; break; }
76
- parent = dirname(parent);
77
- }
78
- if (!regular) { log(`mail migration retained linked source ${row.source}`); continue; }
79
- const file = await stat(path);
80
- if (!file) { done.push(row.source); continue; }
81
- if (!file.isFile()) { log(`mail migration retained changed source ${row.source}`); continue; }
82
- const hash = createHash('sha256');
83
- for await (const bytes of createReadStream(path)) hash.update(bytes);
84
- if (hash.digest('hex') !== row.sha256) { log(`mail migration retained changed bytes ${row.source}`); continue; }
85
- await unlink(path);
86
- done.push(row.source); removed++;
87
- }
88
- // An interruption between unlink and this commit is safe: the next start
89
- // observes the absent original and records its retirement again.
90
- database.transaction(() => { for (const source of done) purged.run(source); });
91
- cursor = batch.at(-1).source;
92
- }
93
- let retained = 0;
94
- async function tidy(dir) {
95
- for (const entry of await readdir(dir, { withFileTypes: true })) {
96
- const path = join(dir, entry.name);
97
- if (entry.isDirectory()) await tidy(path);
98
- else { retained++; log(`mail migration retained diagnostic source ${relative(archive, path)}`); }
99
- }
100
- try { await rmdir(dir); } catch (error) { if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
101
- }
102
- await tidy(archive);
103
- database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), retained_sources: retained });
104
- log(`mail retired: ${removed} imported source files removed; ${retained} diagnostic sources retained at ${archive}`);
91
+ database.setMetadata('maildir_retirement', { ...retirement, inode: snapshot.ino, device: snapshot.dev,
92
+ complete: true, completed_at_ms: Date.now(), originals_retained: true });
93
+ log(`mail retired after complete import; original files retained at ${archive}`);
105
94
  return imported;
106
95
  }
package/mail/send.mjs CHANGED
@@ -732,13 +732,13 @@ export class MailSend {
732
732
  * `sender_identity` is the filer's observation (this daemon's), never the request's own. Answers `{ code, message_id,
733
733
  * new, to }`, or `{ code, text }`.
734
734
  */
735
- file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false } = {}) {
735
+ file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false, notice = false } = {}) {
736
736
  const sender = parseAddress(from.address), receiver = parseAddress(to);
737
737
  if (!sender) return { code: EXIT_REFUSED, text: `\`${from.address}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
738
738
  if (!receiver) return { code: EXIT_REFUSED, text: `\`${to}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
739
739
  let id = messageIdFor(sender.address, key);
740
740
  if (typed) id = `i-${id.replace(/^m-/, '')}`;
741
- const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : 'peer', reply_via: { mode: typed ? 'none' : 'command' }, subject: subjectLine(subject), body });
741
+ const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : notice ? 'notice' : 'peer', reply_via: { mode: typed || notice ? 'none' : 'command' }, subject: subjectLine(subject), body });
742
742
  try {
743
743
  if (this.store.findIn(receiver.address, id)) return { code: 0, message_id: id, new: false, to: receiver.address };
744
744
  // a typed line's record is read already: it reached the session as its composer turn, never as mail