@volter/supercode-teams 0.3.120 → 0.3.122

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
@@ -187,7 +187,12 @@ async function main() {
187
187
  process.on('SIGTERM', stop);
188
188
  return;
189
189
  }
190
- if (sub === 'status') { out(readJson(p.machine, { running: false })); return; }
190
+ if (sub === 'status') {
191
+ // and every supercode process here still running a release an install replaced (t_8e109edc)
192
+ let stale_releases = null;
193
+ try { stale_releases = await (await import('@volter/supercode-health/drift')).staleSupercode(); } catch { /* not read */ }
194
+ out({ ...readJson(p.machine, { running: false }), stale_releases }); return;
195
+ }
191
196
  // `machine restart [--machine M] [--reason text]`: the daemon (this one, or M's through the server) restarts under its service.
192
197
  if (sub === 'restart') return withNode(async (rpc) => out(await rpc.call('machine.restart', { reason: flags.reason ? String(flags.reason) : null })));
193
198
  die('machine start|status|restart');
package/bin/workspace.mjs CHANGED
@@ -2,9 +2,9 @@
2
2
  import { StringDecoder } from 'node:string_decoder';
3
3
  import { hostname } from 'node:os';
4
4
  import { agentSessionHint } from './workspace/agent-session.mjs';
5
- import { fileURLToPath } from 'node:url';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
7
- import { basename, join, resolve } from 'node:path';
7
+ import { basename, dirname, join, resolve } from 'node:path';
8
8
  import { execFileSync, spawn } from 'node:child_process';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
@@ -1119,6 +1119,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
1119
1119
  if (!flags.json) {
1120
1120
  process.stdout.write(`Connected ${saved.machine.name} to team ${context.team_name ?? context.team_id}; it stays connected (a ${service.kind ?? 'background'} service).\n`);
1121
1121
  for (const line of service.message_tools ?? []) process.stdout.write(` ${line}\n`);
1122
+ for (const line of await releaseSkills()) process.stdout.write(` ${line}\n`);
1122
1123
  return;
1123
1124
  }
1124
1125
  return output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, service, ...(addedRule ? { rule: addedRule } : {}) } });
@@ -1486,3 +1487,17 @@ function stampStderr() {
1486
1487
  };
1487
1488
  process.stderr.__stamped = true;
1488
1489
  }
1490
+
1491
+ /**
1492
+ * The skills of the release this Teams package belongs to (`@volter/supercode`'s lib/skills.mjs, the package that
1493
+ * holds it), put into every harness here: a machine connected to a team has agents that know supercode
1494
+ * (t_266912ff). A Teams package run from a checkout or installed alone has no release beside it, and installs none.
1495
+ */
1496
+ async function releaseSkills() {
1497
+ const release = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
1498
+ try {
1499
+ if (JSON.parse(readFileSync(join(release, 'package.json'), 'utf8')).name !== '@volter/supercode') return [];
1500
+ const { installSkills } = await import(pathToFileURL(join(release, 'lib', 'skills.mjs')).href);
1501
+ return installSkills();
1502
+ } catch { return []; }
1503
+ }
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 { mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, watch, writeFileSync } from 'node:fs';
17
+ import { mkdirSync, readFileSync, readdirSync, 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';
@@ -128,7 +128,10 @@ export class LiveSessions extends EventEmitter {
128
128
  this.baseClaudeDirs = [...new Set([join(homedir(), '.claude', 'sessions'), ...(env.CLAUDE_CONFIG_DIR ? [join(env.CLAUDE_CONFIG_DIR, 'sessions')] : [])])];
129
129
  this.receiptsDir = join(supercodeHome, 'live-runtimes');
130
130
  this.homesFile = join(supercodeHome, 'live-claude-homes.json');
131
- this.markersDir = join(supercodeHome, 'mail', 'message-mcp');
131
+ // which session has the messaging tools: told here by each `message mcp` server as it starts (mail step 1c), kept in
132
+ // this daemon's own state, outside the mail folder; the markers servers from before it wrote are taken in once
133
+ this.toolsFile = join(supercodeHome, 'message-tools.json');
134
+ this.legacyMarkersDir = join(supercodeHome, 'mail', 'message-mcp');
132
135
  this.codexHooks = join(env.CODEX_HOME || join(homedir(), '.codex'), 'hooks.json');
133
136
  this.boardOwner = boardOwner; this.log = log; this.startedAt = Date.now();
134
137
  this.claude = new Map(); // pid → { pid, dir, sessionId, name, status, cwd, tmux, procStart, history: [ids, oldest first] }
@@ -171,7 +174,7 @@ export class LiveSessions extends EventEmitter {
171
174
  // Grok's registry, followed where Grok is installed (its home is never made here; a symlinked home is followed at its
172
175
  // target, where its file events are)
173
176
  this.#followGrok();
174
- this.#follow('tools_markers', this.markersDir, (name) => this.#readMarker(name), () => this.#readAll(this.markersDir, (name) => this.#readMarker(name)));
177
+ this.#loadTools();
175
178
  this.#follow('codex_hooks', dirname(this.codexHooks), (name) => { if (!name || name === 'hooks.json') this.#readUserHook(); }, () => this.#readUserHook());
176
179
  return this;
177
180
  }
@@ -258,7 +261,7 @@ export class LiveSessions extends EventEmitter {
258
261
  this.claude.delete(pid);
259
262
  // its messaging tools' marker goes with it when it has ended (step 4a: by its end, never a minute's sweep); an entry
260
263
  // dropped for another reason (its home no longer declared, a parked process) keeps its marker for its return
261
- if (this.markers.has(pid) && !alive(pid)) { this.markers.delete(pid); try { unlinkSync(join(this.markersDir, String(pid))); } catch { /* gone */ } }
264
+ if (this.markers.has(pid) && !alive(pid)) { this.markers.delete(pid); this.#saveTools(); }
262
265
  }
263
266
 
264
267
  /** Every held process asked whether it is alive (signal 0): a dead one's entry goes. */
@@ -476,25 +479,37 @@ export class LiveSessions extends EventEmitter {
476
479
  if (JSON.stringify([...this.grok].map(([id, entry]) => [id, entry.pane])) !== before) this.#changed();
477
480
  }
478
481
 
479
- /** One messaging-tools marker: the session process named by its file, the server pid in it. */
480
- #readMarker(name) {
481
- if (!name) return;
482
- const pid = Number(name);
483
- if (!Number.isInteger(pid)) return;
484
- let server = null;
485
- try { server = Number(readFileSync(join(this.markersDir, name), 'utf8').trim()); } catch { /* removed */ }
486
- // a marker whose server has ended names no tools: removed (read at start, and as each is written or changed)
487
- if (Number.isInteger(server) && server > 0 && !alive(server)) { try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ } server = null; }
488
- if (!(Number.isInteger(server) && server > 0)) { if (this.markers.delete(pid)) this.#changed(); return; }
489
- this.markers.set(pid, server);
490
- // its server is the session's own child (a harness starts its MCP servers so), checked once per marker: a server pid
491
- // used again by another process names no tools (mail_route.rs `has_message_tools` read it so)
482
+ /** The messaging tools' markers kept here, and those servers from before wrote into the mail folder, taken in once
483
+ * (that folder removed): each checked as a new one is. */
484
+ #loadTools() {
485
+ let kept = {};
486
+ try { kept = JSON.parse(readFileSync(this.toolsFile, 'utf8')) ?? {}; } catch { /* none yet */ }
487
+ try {
488
+ for (const name of readdirSync(this.legacyMarkersDir)) { const server = Number(readFileSync(join(this.legacyMarkersDir, name), 'utf8').trim()); if (Number.isInteger(Number(name))) kept[name] = server; }
489
+ rmSync(this.legacyMarkersDir, { recursive: true, force: true });
490
+ } catch { /* none written before */ }
491
+ for (const [session, server] of Object.entries(kept)) this.registerTools(Number(session), Number(server), { save: false });
492
+ this.#saveTools();
493
+ }
494
+
495
+ #saveTools() {
496
+ try { writeFileSync(`${this.toolsFile}.tmp`, JSON.stringify(Object.fromEntries(this.markers))); renameSync(`${this.toolsFile}.tmp`, this.toolsFile); }
497
+ catch (error) { this.log(`live sessions: the messaging tools' markers were not kept: ${error.message}`); }
498
+ }
499
+
500
+ /** A `message mcp` server says the session `session` has the messaging tools while it, `server`, runs: taken when the
501
+ * server lives and is the session's own child (a harness starts its MCP servers so; a server pid used again by
502
+ * another process names no tools, as mail_route.rs `has_message_tools` read it). Answers whether it was taken. */
503
+ registerTools(session, server, { save = true } = {}) {
504
+ if (!Number.isInteger(session) || session <= 0 || !Number.isInteger(server) || server <= 0 || !alive(server)) return false;
505
+ this.markers.set(session, server);
506
+ if (save) this.#saveTools();
507
+ this.#changed();
492
508
  void parentOf(server).then((parent) => {
493
- if (parent == null || parent === pid || this.markers.get(pid) !== server) return;
494
- this.markers.delete(pid);
495
- try { unlinkSync(join(this.markersDir, name)); } catch { /* gone */ }
496
- this.#changed();
509
+ if (parent == null || parent === session || this.markers.get(session) !== server) return;
510
+ this.markers.delete(session); this.#saveTools(); this.#changed();
497
511
  });
512
+ return true;
498
513
  }
499
514
 
500
515
 
package/machine.mjs CHANGED
@@ -771,7 +771,7 @@ export class TeamsMachine extends MachineCore {
771
771
  discover: (params) => this.#serveRequest('harness.v1.sessions.discover', params, 10_000),
772
772
  recordedConfig: (locator) => this.#serveRequest('harness.v1.sessions.recorded_config', { locator }, 15_000) });
773
773
  // a relay's hooks (`message gate`, `relay-receipt`, `relay-inbound`) run the machine's stable supercode: its front door
774
- this.relays = new Relays({ mailRoot, machine, supercodeBin: this.#stableSupercode(), serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
774
+ this.relays = new Relays({ mailRoot, machine, supercodeBin: this.#stableSupercode(), hooksServed: () => this.#frontDoorServes('message gate'), serve: (method, params, timeoutMs) => this.#serveRequest(method, params, timeoutMs),
775
775
  runtimeOf: (harness, id) => live.runtimeReceipt(harness, id), runtimeNamed: (receiptId) => live.receiptNamed(receiptId),
776
776
  runtimesIn: (harness, cwd) => live.runtimesIn(harness, cwd),
777
777
  endpointOf: async () => (await this.relayEndpoint)?.url ?? null,
@@ -800,7 +800,7 @@ export class TeamsMachine extends MachineCore {
800
800
  handOver: (address, id) => this.mailSend.handOver(address, id),
801
801
  // the thread a reply to `id` joins: its filed parent's thread, or the parent itself (mailbox.rs `thread_of_reply`)
802
802
  threadOf: (id) => { const found = this.mailStore.find(id).find((item) => item.stored.envelope.id === id); return found?.stored.envelope.thread ?? id; },
803
- log: (line) => this.emit('mail-log', line) });
803
+ stateDir: supercodeHome(this.env), log: (line) => this.emit('mail-log', line) });
804
804
  // a relay's inbound mail its hook kept while this daemon did not answer (the front door's spool): filed now, each
805
805
  // minute, and before each relay's inbound call
806
806
  this.hookSpool = join(supercodeHome(this.env), 'hook-spool', 'relay-inbound');
@@ -1830,6 +1830,19 @@ export class TeamsMachine extends MachineCore {
1830
1830
  * this daemon's native once per daemon (`__stable-program`), for a command a pane records; this daemon's own
1831
1831
  * supercode when its native has no such verb or the shim cannot be written.
1832
1832
  */
1833
+ /** Whether the machine's stable supercode serves `command` in TypeScript (its front door's own list), asked once: a dev
1834
+ * or cargo build's native, run with no front door, serves no hook verb. */
1835
+ #frontDoorServes(command) {
1836
+ if (this.frontDoorServed === undefined) {
1837
+ this.frontDoorServed = new Set();
1838
+ try {
1839
+ const answer = spawnSync(this.#stableSupercode(), ['front-door', '--json'], { env: this.env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
1840
+ if (answer.status === 0) for (const row of JSON.parse(answer.stdout).typescript ?? []) this.frontDoorServed.add(row.command);
1841
+ } catch { /* none served */ }
1842
+ }
1843
+ return this.frontDoorServed.has(command);
1844
+ }
1845
+
1833
1846
  #stableSupercode() {
1834
1847
  if (this.stableSupercodePath !== undefined) return this.stableSupercodePath ?? this.supercodeBin;
1835
1848
  this.stableSupercodePath = null;
@@ -1922,14 +1935,7 @@ export class TeamsMachine extends MachineCore {
1922
1935
  if (process.platform === 'win32' || !((params.kind === 'codex' && name === 'codex') ||
1923
1936
  (params.kind === 'claude-code' && name === 'claude'))) return launchCommand(params.program, args);
1924
1937
  // the hook is the front door's (mail step 1b): taken when the machine's stable supercode serves it
1925
- if (this.nativeQuestionHooksSupported === undefined) {
1926
- let served = false;
1927
- try {
1928
- const answer = spawnSync(this.#stableSupercode(), ['front-door', '--json'], { env: this.env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
1929
- served = answer.status === 0 && (JSON.parse(answer.stdout).typescript ?? []).some((row) => row.command === 'message native-question-hook');
1930
- } catch { /* not served: the launch runs without it */ }
1931
- this.nativeQuestionHooksSupported = served;
1932
- }
1938
+ this.nativeQuestionHooksSupported ??= this.#frontDoorServes('message native-question-hook');
1933
1939
  if (!this.nativeQuestionHooksSupported) return launchCommand(params.program, args);
1934
1940
  const mailHome = this.env.SUPERCODE_HOME || join(this.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
1935
1941
  const dir = join(mailHome, 'mail', 'native-launches');
@@ -3866,6 +3872,82 @@ export class TeamsMachine extends MachineCore {
3866
3872
  if (method === 'harness.v1.mail.relay_inbound') { this.mailHooks.drainSpool(this.hookSpool); return { print: this.mailHooks.inbound(params.represented, params.directory, input, params.call) }; }
3867
3873
  return { print: await this.mailHooks.questionHook(params.binding, input, params.claim, this.nativeQuestions) };
3868
3874
  }
3875
+ // a `message mcp` server saying its session has the messaging tools while it runs (mail step 1c): the server is the
3876
+ // caller's own process (its claim), or on Windows the native that started it (`server`), the session's child either way
3877
+ case 'harness.v1.mail.tools': {
3878
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('the messaging tools are said here by this machine\'s own processes'), { code: 403 });
3879
+ const server = Number.isInteger(params.server_pid) ? params.server_pid : Number(params.claim?.pid);
3880
+ return { taken: this.live.registerTools(Number(params.session_pid), server) };
3881
+ }
3882
+ // a board's own mailbox (mail step 1c): its owner's round claims its unread mail (board_inbox), and says which it
3883
+ // handled (board_ack: those read, the rest given back unread); a dispatcher waits on it (board_wait) instead of
3884
+ // watching the mail folder
3885
+ case 'harness.v1.mail.board_inbox': case 'harness.v1.mail.board_ack': case 'harness.v1.mail.board_wait': {
3886
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('a board\'s mail is read here by this machine\'s own processes'), { code: 403 });
3887
+ const board = new RegExp(`^sc:${machineName(this.env).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:board:[^:\\s]+$`);
3888
+ if (method === 'harness.v1.mail.board_wait') {
3889
+ const boards = (Array.isArray(params.boards) ? params.boards : []).filter((address) => typeof address === 'string' && board.test(address)).slice(0, 256);
3890
+ const seconds = Math.min(120, Math.max(0, Number.isFinite(Number(params.timeout)) ? Math.floor(Number(params.timeout)) : 60));
3891
+ const waiting = () => boards.filter((address) => this.mailStore.hasClaimable(address));
3892
+ let woken = waiting();
3893
+ if (!woken.length && boards.length) {
3894
+ woken = await new Promise((resolve) => {
3895
+ const changed = (address) => { if (boards.includes(address)) { const now = waiting(); if (now.length) done(now); } };
3896
+ const done = (value) => { clearTimeout(timer); this.mailStore.off('changed', changed); resolve(value); };
3897
+ const timer = setTimeout(() => done([]), seconds * 1000);
3898
+ this.mailStore.on('changed', changed);
3899
+ });
3900
+ }
3901
+ return { woken };
3902
+ }
3903
+ let caller = null;
3904
+ try { caller = await this.#callerOf(entry, params); } catch { /* not a registered owner's: the lock below may say */ }
3905
+ if (typeof caller !== 'string' || !board.test(caller)) {
3906
+ // a board on a machine that serves no team has no registered owner (registration is its connector's): its round
3907
+ // names its home and holds that home's board lock, and its address is one of that home's boards
3908
+ const named = params.claim?.named, root = typeof params.root === 'string' && params.root ? params.root : null;
3909
+ caller = null;
3910
+ if (root && typeof named === 'string' && board.test(named) && await this.#holdsBoardLock(root, Number(params.claim?.pid))) {
3911
+ let slugs = ['default'];
3912
+ try { slugs = [...slugs, ...readdirSync(join(root, 'kanban', 'boards'))]; } catch { /* the default board only */ }
3913
+ if (slugs.some((slug) => `sc:${machineName(this.env)}:board:${boardName(boardLabel(root, slug))}` === named)) caller = named;
3914
+ }
3915
+ }
3916
+ if (!caller) return { code: 2, text: 'a board\'s mailbox is read by that board\'s own owner only (its registered owner, or the process holding its home\'s board lock)' };
3917
+ this.boardClaims ??= new Map();
3918
+ if (method === 'harness.v1.mail.board_ack') {
3919
+ const pending = this.boardClaims.get(params.token);
3920
+ if (!pending || pending.address !== caller) return { code: 0, acknowledged: 0 };
3921
+ this.boardClaims.delete(params.token);
3922
+ const handled = new Set(Array.isArray(params.ids) ? params.ids : []);
3923
+ let acknowledged = 0;
3924
+ for (const item of pending.claimed) {
3925
+ try { if (handled.has(item.envelope.id)) { this.mailStore.acknowledge(caller, item); acknowledged++; } else this.mailStore.release(caller, item); }
3926
+ catch { /* moved meanwhile: its reader's recovery gives it back */ }
3927
+ }
3928
+ return { code: 0, acknowledged };
3929
+ }
3930
+ const claimed = this.mailStore.claimUnread(caller, 'board', caller, Number(params.claim?.pid) || process.pid);
3931
+ if (!claimed.length) return { code: 0, token: null, envelopes: [] };
3932
+ const token = randomBytes(12).toString('hex');
3933
+ for (const [old] of [...this.boardClaims].slice(0, Math.max(0, this.boardClaims.size - 63))) this.boardClaims.delete(old);
3934
+ this.boardClaims.set(token, { address: caller, claimed });
3935
+ return { code: 0, token, envelopes: claimed.map((item) => this.mailStore.body(item) ?? item.envelope) };
3936
+ }
3937
+ // the Codex hooks' records (Windows), for serve's Codex listing (mail step 1c)
3938
+ case 'harness.v1.mail.codex_hooks': {
3939
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('the Codex hooks\' records are read here by this machine\'s own processes'), { code: 403 });
3940
+ return { records: this.mailHooks.codexHookRecords() };
3941
+ }
3942
+ // the names ledger, kept here from the live map (mail step 1c): the session each name last meant, and the name each
3943
+ // session was last listed by, for the native readers (`message send` by a remembered name, `open`, a session tree)
3944
+ case 'harness.v1.mail.names': {
3945
+ if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('the names ledger is read here by this machine\'s own processes'), { code: 403 });
3946
+ const list = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string').slice(0, 4096) : []);
3947
+ const addresses = {};
3948
+ for (const name of list(params.names)) { const address = this.mailSend.remembered(name); if (address) addresses[name] = address; }
3949
+ return { addresses, names: this.mailSend.rememberedNames(list(params.addresses)) };
3950
+ }
3869
3951
  // `message waiting` and `message expired` (step 1): the carrier's own records
3870
3952
  case 'harness.v1.mail.waiting': case 'harness.v1.mail.expired': {
3871
3953
  if (entry.principal !== 'local' || entry.appDoor) throw Object.assign(new Error('mail is read here by this machine\'s own processes'), { code: 403 });
package/mail/hooks.mjs CHANGED
@@ -32,8 +32,13 @@ const printable = (name) => Array.from(String(name ?? '')).filter((c) => /[A-Za-
32
32
 
33
33
  export class MailHooks {
34
34
  /** `handOver(address, id)` a filed message to its session's door; `threadOf(id)` the thread a reply to `id` joins. */
35
- constructor({ machine, mailRoot, store, live, handOver, threadOf, log = () => {} }) {
35
+ constructor({ machine, mailRoot, store, live, handOver, threadOf, stateDir = null, log = () => {} }) {
36
36
  Object.assign(this, { machine, mailRoot, store, live, handOver, threadOf, log });
37
+ // Windows: each Codex session's last hook (the process it ran under, the rollout it named, when), which serve's
38
+ // Codex listing reads through this daemon (codex_peer.rs `hook_recorded_rollouts`); kept beside this daemon's state,
39
+ // never in the mail folder (mail step 1c)
40
+ this.codexHooksFile = stateDir ? join(stateDir, 'codex-hooks.json') : null;
41
+ this.codexHooks = new Map(Object.entries((this.codexHooksFile && readJson(this.codexHooksFile)) || {}));
37
42
  this.relaysRoot = resolve(mailRoot, 'relays');
38
43
  this.questions = join(mailRoot, 'native-questions');
39
44
  }
@@ -49,7 +54,8 @@ export class MailHooks {
49
54
  // Windows: where the listing cannot see a Codex's open rollout, the session's own hook says it runs (the process
50
55
  // that ran it, and the rollout it names), until the live map knows Codex there (step 1c)
51
56
  if (process.platform === 'win32' && harness === 'codex' && Number.isInteger(claim?.ppid)) {
52
- try { writeFileSync(join(box.dir, 'process'), `${claim.ppid}\n${typeof input.transcript_path === 'string' ? input.transcript_path : ''}\n`); } catch { /* best kept */ }
57
+ this.codexHooks.set(address, { pid: claim.ppid, rollout: typeof input.transcript_path === 'string' ? input.transcript_path : '', at_ms: Date.now() });
58
+ this.#saveCodexHooks();
53
59
  }
54
60
  const event = typeof input.hook_event_name === 'string' ? input.hook_event_name : '';
55
61
  // the person's own prompt: filed as theirs, since the harness's transcript does not say who wrote it; no pointer
@@ -68,6 +74,21 @@ export class MailHooks {
68
74
  : { decision: 'block', reason: `${pointer} Read them before you finish.` };
69
75
  }
70
76
 
77
+ /** The Codex hooks' records (Windows), for serve's listing: `{address, pid, rollout, at_ms}` each; one whose process is
78
+ * gone is let go. */
79
+ codexHookRecords() {
80
+ let dropped = false;
81
+ for (const [address, record] of this.codexHooks) if (!alive(record.pid)) { this.codexHooks.delete(address); dropped = true; }
82
+ if (dropped) this.#saveCodexHooks();
83
+ return [...this.codexHooks].map(([address, record]) => ({ address, ...record }));
84
+ }
85
+
86
+ #saveCodexHooks() {
87
+ if (!this.codexHooksFile) return;
88
+ try { writeFileSync(`${this.codexHooksFile}.tmp`, JSON.stringify(Object.fromEntries(this.codexHooks))); renameSync(`${this.codexHooksFile}.tmp`, this.codexHooksFile); }
89
+ catch (error) { this.log(`codex hooks: not kept: ${error.message}`); }
90
+ }
91
+
71
92
  /** How many unread messages, from whom, and the one command that reads them (message.rs `mail_pointer`). */
72
93
  #pointer(unread) {
73
94
  const senders = [];
@@ -174,7 +195,12 @@ export class MailHooks {
174
195
  // drain (its id names its mail, so a filing that half happened is not made twice)
175
196
  if (spooled?.input) {
176
197
  try { this.inbound(spooled.represented, spooled.directory, spooled.input, spooled.call); filed++; }
177
- catch (error) { this.log(`relay inbound: kept mail ${name} not filed yet (${error.message}); kept for the next drain`); continue; }
198
+ catch (error) {
199
+ // one this daemon refuses (its folder no longer a relay's, its address not one: a 400) never will be filed: let
200
+ // go, said; one that failed on its way to the store (an I/O failure) is kept for the next drain
201
+ if (error.code === 400) this.log(`relay inbound: kept mail ${name} refused (${error.message}); let go`);
202
+ else { this.log(`relay inbound: kept mail ${name} not filed yet (${error.message}); kept for the next drain`); continue; }
203
+ }
178
204
  }
179
205
  rmSync(path, { force: true });
180
206
  }
package/mail/relay.mjs CHANGED
@@ -91,8 +91,8 @@ export class Relays {
91
91
  * id)` the live-runtime receipt the live-session map holds for a runtime id (its file read whole), or null;
92
92
  * `runtimeNamed(receiptId)` one receipt read by its own name.
93
93
  */
94
- constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, log = () => {} }) {
95
- Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, log });
94
+ constructor({ mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed = () => null, runtimesIn = () => [], endpointOf = async () => null, arrived = async () => false, hooksServed = () => true, log = () => {} }) {
95
+ Object.assign(this, { mailRoot, machine, supercodeBin, serve, runtimeOf, runtimeNamed, runtimesIn, endpointOf, arrived, hooksServed, log });
96
96
  this.lost = new Map(); // relay folder → when a start of its runtime lost its answer (the runtime may run, unnamed)
97
97
  this.remoteKept = new Map(); // relay folder of a sender on another machine → the timer that retires it
98
98
  this.sending = new Map(); // relay folder → the send in progress through it (one at a time per relay)
@@ -268,6 +268,9 @@ export class Relays {
268
268
  /** The relay's runtime, started in this daemon's harness serve when it is not running (claude_relay.rs
269
269
  * `ensure_relay_runtime`): `{ runtime (its receipt), connection }`. */
270
270
  async #ensure(spec, step = () => {}, { stale = false } = {}) {
271
+ // a relay's hooks are the front door's verbs (mail step 1b): a stable supercode with no front door (a dev or cargo
272
+ // build's native) would answer none of them, and every mail the relay is handed would be blocked unfiled
273
+ if (!this.hooksServed()) throw new Error('relays run their hooks through the machine\'s stable supercode, which has no front door here (a dev or cargo build); install supercode from npm to send to Claude sessions by relay');
271
274
  const endpoint = await this.endpoint();
272
275
  step('relay endpoint');
273
276
  if (!endpoint) throw new Error('the relay endpoint is not served by this machine\'s daemon (its log says why); a Claude session can be sent mail once the daemon starts again');
package/mail/send.mjs CHANGED
@@ -890,6 +890,24 @@ export class MailSend {
890
890
  /** The session a name last meant here, listed by it within NAME_KEPT_MS (mail_route.rs `remembered_address`). */
891
891
  remembered(name) { return this.#rememberedAddress(name); }
892
892
 
893
+ /** The name `address` was last listed by here (the newest it began to carry), or null (mail_route.rs
894
+ * `remembered_name`). */
895
+ rememberedName(address) { return this.rememberedNames([address])[address] ?? null; }
896
+
897
+ /** rememberedName for each of `addresses`, the ledger read once. */
898
+ rememberedNames(addresses) {
899
+ const names = readJson(join(this.mailRoot, 'names.json'));
900
+ const wanted = new Set(addresses), best = new Map();
901
+ if (names && typeof names === 'object') {
902
+ for (const [name, record] of Object.entries(names)) {
903
+ if (!wanted.has(record?.address)) continue;
904
+ const held = best.get(record.address);
905
+ if (!held || (record.since_ms ?? 0) > (held.since_ms ?? 0)) best.set(record.address, { name, since_ms: record.since_ms ?? 0 });
906
+ }
907
+ }
908
+ return Object.fromEntries([...best].map(([address, { name }]) => [address, name]));
909
+ }
910
+
893
911
  #rememberedAddress(name) {
894
912
  const record = readJson(join(this.mailRoot, 'names.json'))?.[name];
895
913
  if (!record || Date.now() - Math.max(record.seen_ms ?? 0, record.since_ms ?? 0) > NAME_KEPT_MS) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.120",
3
+ "version": "0.3.122",
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",