@volter/supercode-teams 0.3.187 → 0.3.189

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/live-sessions.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  // - Messaging tools: the marker a `message mcp` server writes for its session process, with the server alive.
15
15
  import { EventEmitter } from 'node:events';
16
16
  import { execFile } from 'node:child_process';
17
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, watch, writeFileSync } from 'node:fs';
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, unlinkSync, watch, writeFileSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
19
  import { dirname, join } from 'node:path';
20
20
  import { FrontendClient } from '@volter/supercode-frontend';
@@ -849,8 +849,7 @@ async function ancestryOf(pid, timeout = 5000) {
849
849
  * (2026-10-07: "exactly one rollout" left a session that had spawned subagents unable to read or send its own mail.)
850
850
  */
851
851
  async function rolloutSession(pid, thread, timeout = 5000) {
852
- const out = await new Promise((resolve) => execFile(tool('lsof'), ['-a', '-p', String(pid), '-Fn'], { timeout, maxBuffer: 16 * 1024 * 1024 }, (_error, stdout) => resolve(String(stdout ?? ''))));
853
- const rollouts = [...new Set(out.split('\n').filter((line) => line.startsWith('n') && /rollout-[^/]*\.jsonl$/.test(line)).map((line) => line.slice(1)))];
852
+ const rollouts = [...new Set((await openFiles(pid, timeout)).filter((path) => /rollout-[^/]*\.jsonl$/.test(path)))];
854
853
  // the thread each rollout is of, by the id its file is named with: the claim is one of those ids whole, never a part
855
854
  // of a path (a rollout's name also carries its date, in the same letters a thread id is checked for)
856
855
  const idOf = (path) => /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(path)?.[1] ?? null;
@@ -877,6 +876,24 @@ async function rolloutSession(pid, thread, timeout = 5000) {
877
876
  return roots[0];
878
877
  }
879
878
 
879
+ /**
880
+ * The files process `pid` holds open, by path. Linux keeps them as links under /proc/<pid>/fd, read with no program (a
881
+ * machine there may have no lsof at all, and every Codex session on it was then no one to mail); a process whose table
882
+ * this one may not read, and every other system, is asked of lsof. Empty when neither can say.
883
+ */
884
+ async function openFiles(pid, timeout = 5000) {
885
+ if (process.platform === 'linux') {
886
+ try {
887
+ const dir = `/proc/${pid}/fd`;
888
+ const paths = [];
889
+ for (const fd of readdirSync(dir)) { try { paths.push(readlinkSync(`${dir}/${fd}`)); } catch { /* closed since it was listed */ } }
890
+ return paths;
891
+ } catch { /* another user's process, or no /proc mounted: lsof, where there is one */ }
892
+ }
893
+ const out = await new Promise((resolve) => execFile(tool('lsof'), ['-a', '-p', String(pid), '-Fn'], { timeout, maxBuffer: 16 * 1024 * 1024 }, (_error, stdout) => resolve(String(stdout ?? ''))));
894
+ return out.split('\n').filter((line) => line.startsWith('n')).map((line) => line.slice(1));
895
+ }
896
+
880
897
  /** A rollout's first line (its session's own record), read from the file's head; null when it cannot be read. */
881
898
  function rolloutHead(path) {
882
899
  let file = null;
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
@@ -717,6 +730,7 @@ export class TeamsMachine extends MachineCore {
717
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 }),
@@ -895,6 +909,30 @@ export class TeamsMachine extends MachineCore {
895
909
  const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
896
910
  const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
897
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
+ }
898
936
  const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
899
937
  if (result.refused) throw new Error(result.refused.reason);
900
938
  return result.waits ?? result;
@@ -1028,6 +1066,15 @@ export class TeamsMachine extends MachineCore {
1028
1066
  try { activities = (await this.#serveRequest('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }, 30_000))?.activities ?? []; }
1029
1067
  catch (error) { sessions_unread = String(error?.message ?? error).slice(0, 200); }
1030
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
+ }
1031
1078
  this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid,
1032
1079
  activity: sessions_unread ? null : activities.find(row => row.pid === pane.pid)?.activity ?? null })), { current: sessions_unread == null });
1033
1080
  // the sessions under the panes unread: the panes are read again on the section's backoff until they are (MR4)
@@ -2075,7 +2122,7 @@ export class TeamsMachine extends MachineCore {
2075
2122
  /** The home and board a board name (an address's last part) names among the homes registered with this daemon, by the
2076
2123
  * one rule the board names itself with (@volter/teams/board-name). */
2077
2124
  #boardKept(name) {
2078
- 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() ?? [])])) {
2079
2126
  if (boardName(boardLabel(root, 'default')) === name) return { root, board: 'default' };
2080
2127
  let named = [];
2081
2128
  try { named = readdirSync(join(root, 'kanban', 'boards')); } catch { /* no named boards */ }
@@ -2577,6 +2624,23 @@ export class TeamsMachine extends MachineCore {
2577
2624
  return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
2578
2625
  }
2579
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
+
2580
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
2581
2645
  * (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
2582
2646
  * reaches this one only through its link, so a return address is never an identity. */
@@ -2590,7 +2654,7 @@ export class TeamsMachine extends MachineCore {
2590
2654
  async #runFiling(request) {
2591
2655
  if (!this.mailSend) return { code: 1, text: 'this machine\'s daemon has not started its mail yet' };
2592
2656
  return this.mailSend.file({ address: request.from, name: request.from_name ?? '' }, request.to, request.body ?? '', {
2593
- 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,
2594
2658
  sender_identity: request.sender_identity && typeof request.sender_identity === 'object' ? request.sender_identity : null, typed: request.typed === true,
2595
2659
  });
2596
2660
  }
@@ -4501,7 +4565,7 @@ export class TeamsMachine extends MachineCore {
4501
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 });
4502
4566
  const sender_identity = await this.#observeSender(params.from, params.from_name ?? null, typeof params.context === 'string' && params.context ? params.context : null);
4503
4567
  const answer = await this.#runFiling({ from: params.from, from_name: params.from_name ?? '', to: params.to, body: params.body, id: params.id ?? null,
4504
- 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 });
4505
4569
  act('mail.file', params.to, answer?.code === 0 ? 'ok' : 'failed');
4506
4570
  return answer;
4507
4571
  }
@@ -4814,21 +4878,26 @@ export class TeamsMachine extends MachineCore {
4814
4878
  case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
4815
4879
  // A board home this machine keeps, published to its team by its connector (board-events.mjs).
4816
4880
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
4817
- 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 });
4818
- if (method === 'harness.v1.boards.list') return this.boardEvents.list();
4881
+ if (method === 'harness.v1.boards.list') return this.boardEvents?.list() ?? [];
4819
4882
  if (method === 'harness.v1.boards.unregister') {
4820
4883
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4821
- 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 };
4822
4888
  }
4823
4889
  if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
4824
- const registered = this.boardEvents.register(params.root);
4890
+ const registered = this.boardEvents?.register(params.root) ?? { root: resolve(params.root), published: false };
4825
4891
  // The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
4826
4892
  // (#callerOf). The owner is the process holding the home's board lock (board.lock, through its lock holder), as
4827
4893
  // the kernel says: the registering client names its pid (its claim), and only a pid that holds the lock is
4828
4894
  // taken. A registration from any other process publishes the home and grants nothing, and never replaces the
4829
4895
  // owner's.
4830
4896
  const pid = entry.principal === 'local' && !entry.appDoor ? Number(params.claim?.pid) : NaN;
4831
- 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
+ }
4832
4901
  act('boards.register', registered.root);
4833
4902
  return registered;
4834
4903
  }
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.187",
3
+ "version": "0.3.189",
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",
@@ -273,7 +273,8 @@ export class SessionIndexOwner extends EventEmitter {
273
273
  // Lifecycle parents cover replacement of the selected root, not every
274
274
  // sibling below that parent. Only selected physical directory bindings
275
275
  // extend file-source admission beyond its logical aliases.
276
- const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname)]);
276
+ const parents = new Set([dirname(source.root), ...[...source.aliases].map(dirname),
277
+ ...(source.registryPath ? [dirname(source.registryPath)] : [])]);
277
278
  const selectedTargets = source.kind === 'stores' ? targets : targets.filter((target) => !parents.has(target));
278
279
  if (path != null && !pathUnder(source.root, path) && !pathUnder(path, source.root)
279
280
  && ![...selectedTargets, ...source.aliases].some((target) => pathUnder(path, target) || pathUnder(target, path))
@@ -349,6 +350,12 @@ export class SessionIndexOwner extends EventEmitter {
349
350
  });
350
351
  if (this.closed) return;
351
352
  for (const part of source.parts.values()) if (part.kind === 'file' && (folder === source.root || pathUnder(folder, part.path)) && !listing.parts.has(part.path)) this.#remove(source, part.path);
353
+ // A previous watcher may have admitted a sibling of an aliased registry.
354
+ // Only a completed full listing proves which transcript paths belong here;
355
+ // malformed files still selected retain their failure and retry evidence.
356
+ if (folder === source.root) for (const failure of this.store.failures([source.id])) {
357
+ if (failure.kind === 'file' && !listing.parts.has(failure.path)) this.store.clearFailure(source.id, failure.path, failure.kind);
358
+ }
352
359
  for (const path of listing.parts) { if (this.closed) return; await this.#file(source, path); }
353
360
  if (this.closed) return;
354
361
  this.store.clearFailure(source.id, folder, 'folder');