@volter/supercode-health 0.1.18 → 0.1.20

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/lib/drift.mjs CHANGED
@@ -43,34 +43,48 @@ async function runningInodes(pids) {
43
43
  /** `ps`'s start time (`lstart`, as `Tue Oct 6 12:09:42 2026`) in ms, or NaN. */
44
44
  const startedMs = (lstart) => Date.parse(String(lstart).trim().replace(/\s+/g, ' ').replace(/^\w{3} /, ''));
45
45
 
46
- /**
47
- * The running supercode processes (on macOS and Linux) whose install file was written after they started:
48
- * `[{ pid, command, file, startedAt, installedAt }]`, newest install first. `rows` (ps lines, for a reading without
49
- * ps) is `pid lstart(5 words) command`.
50
- */
51
- export async function staleSupercode({ rows = null } = {}) {
52
- const lines = rows ?? String((await run('ps', ['-A', '-ww', '-o', 'pid=,lstart=,command='], { timeoutMs: 10_000 })).stdout ?? '').split('\n');
53
- const found = [];
46
+ /** The supercode install processes among `lines` (`pid [ppid tpgid] lstart(5 words) command`), each with the file it runs. */
47
+ function installProcesses(lines, withParent) {
48
+ const shape = withParent ? /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/u : /^\s*(\d+)()()\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/u;
49
+ const table = new Map(), found = [];
54
50
  for (const line of lines) {
55
- const match = /^\s*(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+[\d:]+\s+\d{4})\s+(.*)$/u.exec(line);
51
+ const match = shape.exec(line);
56
52
  if (!match) continue;
57
- const [, pid, lstart, command] = match;
53
+ const [, pid, ppid, tty, lstart, command] = match;
54
+ // on a terminal: its terminal has a foreground group (none reads 0 or -1); asking ps the terminal's name costs 0.3 s
55
+ const row = { pid: Number(pid), ppid: withParent ? Number(ppid) : null, tty: withParent && Number(tty) > 0, lstart, command };
56
+ table.set(row.pid, row);
58
57
  const program = ran(command);
59
- if (program) found.push({ pid: Number(pid), lstart, command, ...program });
58
+ if (program) found.push({ ...row, ...program });
60
59
  }
61
- // a native is read by the inode it runs, where lsof says it: the strongest reading (a ctime also moves on a chmod)
62
- const inodes = rows ? new Map() : await runningInodes(found.filter((row) => row.native).map((row) => row.pid));
63
- const stale = [];
64
- for (const { pid, lstart, command, file } of found) {
60
+ return { table, found };
61
+ }
62
+
63
+ /** Each of `found` with whether the file it runs was replaced after it started, and when that file was last written.
64
+ * A native is read by the inode it runs, where lsof says it: the strongest reading (a ctime also moves on a chmod). */
65
+ function judged(found, inodes) {
66
+ const rows = [];
67
+ for (const { pid, ppid, tty, lstart, command, file } of found) {
65
68
  const started = startedMs(lstart);
66
69
  let stat;
67
70
  try { stat = statSync(file); } catch { continue; } // a file gone is a removed install, said elsewhere
68
71
  const installed = stat.ctimeMs;
69
- if (inodes.has(pid)) {
70
- if (inodes.get(pid) === stat.ino) continue;
71
- } else if (Number.isNaN(started) || installed <= started + 1000) continue; // ps says a start to the second
72
- stale.push({ pid: Number(pid), command: command.slice(0, 200), file, startedAt: new Date(started).toISOString(), installedAt: new Date(installed).toISOString() });
72
+ const stale = inodes.has(pid) ? inodes.get(pid) !== stat.ino : !(Number.isNaN(started) || installed <= started + 1000); // ps says a start to the second
73
+ rows.push({ pid, ppid, tty, command: command.slice(0, 200), file, startedAt: Number.isNaN(started) ? null : new Date(started).toISOString(), installedAt: new Date(installed).toISOString(), stale });
73
74
  }
75
+ return rows;
76
+ }
77
+
78
+ /**
79
+ * The running supercode processes (on macOS and Linux) whose install file was written after they started:
80
+ * `[{ pid, command, file, startedAt, installedAt }]`, newest install first. `rows` (ps lines, for a reading without
81
+ * ps) is `pid lstart(5 words) command`.
82
+ */
83
+ export async function staleSupercode({ rows = null } = {}) {
84
+ const lines = rows ?? String((await run('ps', ['-A', '-ww', '-o', 'pid=,lstart=,command='], { timeoutMs: 10_000 })).stdout ?? '').split('\n');
85
+ const { found } = installProcesses(lines, false);
86
+ const inodes = rows ? new Map() : await runningInodes(found.filter((row) => row.native).map((row) => row.pid));
87
+ const stale = judged(found, inodes).filter((row) => row.stale).map(({ pid, command, file, startedAt, installedAt }) => ({ pid, command, file, startedAt, installedAt }));
74
88
  return moved(stale).sort((a, b) => b.installedAt.localeCompare(a.installedAt));
75
89
  }
76
90
 
@@ -114,3 +128,122 @@ export function pinnedHomes({ home = null } = {}) {
114
128
  }
115
129
  return pinned;
116
130
  }
131
+
132
+ /** The service each process is one of, by pid, where the service manager says it: launchd's label for the processes it
133
+ * started (this user's domain: one `launchctl list`), a systemd unit from the process's own cgroup. Empty where
134
+ * neither answers. */
135
+ async function serviceLabels(pids) {
136
+ const labels = new Map();
137
+ if (process.platform === 'darwin') {
138
+ const listed = await run('/bin/launchctl', ['list'], { timeoutMs: 3000 }).catch(() => null);
139
+ for (const line of String(listed?.stdout ?? '').split('\n')) {
140
+ const row = /^(\d+)\s+\S+\s+(\S+)$/u.exec(line);
141
+ if (row) labels.set(Number(row[1]), `launchd ${row[2]}`);
142
+ }
143
+ } else if (process.platform === 'linux') {
144
+ for (const pid of pids) {
145
+ try {
146
+ const unit = readFileSync(`/proc/${pid}/cgroup`, 'utf8').trim().split('\n').pop().split('/').pop();
147
+ if (unit.endsWith('.service') && !/^user@\d+\.service$/u.test(unit)) labels.set(pid, `systemd ${unit}`);
148
+ } catch { /* gone, or not ours to read */ }
149
+ }
150
+ }
151
+ return labels;
152
+ }
153
+
154
+ /**
155
+ * Every process and every supercode install process of this machine, for the readings that need who started what:
156
+ * `{ table: Map(pid → { pid, ppid, tty, lstart, command }), running: [{ pid, ppid, tty, command, file, startedAt,
157
+ * installedAt, stale }], labels: Map(pid → 'launchd <label>' | 'systemd <unit>') }`. `stale` is staleSupercode's reading: the file
158
+ * the process runs, in the install it runs from, was replaced after it started. `lines` and `labels` stand in for ps
159
+ * and the service manager (a recorded listing: `pid ppid tpgid lstart command`).
160
+ */
161
+ export async function supercodeProcesses({ lines = null, labels = null } = {}) {
162
+ const listed = lines ? null : await run('ps', ['-A', '-ww', '-o', 'pid=,ppid=,tpgid=,lstart=,command='], { timeoutMs: 10_000 });
163
+ const { table, found } = installProcesses(lines ?? String(listed.stdout ?? '').split('\n'), true);
164
+ const [inodes, services] = await Promise.all([
165
+ lines ? new Map() : runningInodes(found.filter((row) => row.native).map((row) => row.pid)),
166
+ labels ?? serviceLabels(found.map((row) => row.pid).concat(found.map((row) => row.ppid))),
167
+ ]);
168
+ const rows = judged(found, inodes);
169
+ const still = new Set(moved(rows.filter((row) => row.stale)).map((row) => row.pid));
170
+ return { table, running: rows.map((row) => ({ ...row, stale: row.stale && still.has(row.pid) })), labels: services };
171
+ }
172
+
173
+ const wordsOf = (command) => String(command ?? '').split(/\s+/u).filter(Boolean);
174
+ const base = (word) => String(word ?? '').split('/').pop();
175
+ /** The script node runs, or the program itself: what a process is, whatever started it. */
176
+ const scriptOf = (command) => { const words = wordsOf(command); return /(^|\/)node$/u.test(words[0] ?? '') ? words.slice(1).find((word) => !word.startsWith('-')) ?? words[0] : words[0]; };
177
+ /** A process as a reading names it: its program and the script it runs, and a `--root` where it has one; never its
178
+ * other arguments (a harness's carry a prompt, a command's a path of someone's). */
179
+ function named(command) {
180
+ const words = wordsOf(command), script = scriptOf(command);
181
+ const root = words.indexOf('--root');
182
+ const shown = [base(words[0]), ...(script !== words[0] ? [script] : words[1]?.startsWith('/') ? [words[1]] : [])].join(' ');
183
+ return `${shown}${root > 0 && words[root + 1] ? ` --root ${words[root + 1]}` : ''}`.slice(0, 240);
184
+ }
185
+ /** What a supercode process does: its script's last two path parts and its verb (the first plain words after it). */
186
+ function doing(command) {
187
+ const words = wordsOf(command), script = scriptOf(command);
188
+ const verb = words.slice(words.indexOf(script) + 1).filter((word) => /^[a-z][a-z-]*$/u.test(word)).slice(0, 2).join(' ');
189
+ return `${script.split('/').slice(-2).join('/')}${verb ? ` ${verb}` : ''}`;
190
+ }
191
+
192
+ /**
193
+ * The supercode install processes of a `supercodeProcesses` reading, by what replacing a release asks of each:
194
+ *
195
+ * - `helpers`: a process that lives exactly as long as one session and cannot take new code without that session
196
+ * starting again (the Codex pane's wrapper, and anything a harness started: its mail MCP server, that server's
197
+ * child). Told by where it runs: it is a pane wrapper, or a harness (`sessionPrograms`) or a pane wrapper is above
198
+ * it, or it is on a terminal (a pane's or a person's: a harness this list does not name, a command someone runs).
199
+ * Running a replaced release is what these do until their session ends; they are counted, never a broken promise.
200
+ * - `orphans`: a supercode command with no parent left (its parent is the system's first process) that no service
201
+ * manager started and that is not the machine's daemon, running longer than `orphanMin`. A stuck command, whatever
202
+ * release it runs.
203
+ * - `services`: every other process running a replaced release: what an install should have restarted (the daemon and
204
+ * its children, a board keeper's, another product's service). A process whose parent is one of these too is the same
205
+ * service (node and the native it starts). Each names who owns it (the service manager's label of it or of the
206
+ * nearest process above it that has one, and its parent), the install its file is in, and since when that file is
207
+ * known replaced: `remembered` (`pid:startedAt` → ISO time, a reading before this one) when it is earlier than the
208
+ * file's last write, which a later install moves.
209
+ */
210
+ export function classifyReleaseProcesses({ table, running, labels = new Map() }, { now = Date.now(), sessionPrograms = ['claude', 'codex', 'gemini', 'grok'], daemonPid = null, orphanMin = 30, remembered = {} } = {}) {
211
+ const isPane = (command) => base(scriptOf(command)) === 'native-codex-pane.mjs';
212
+ const isHarness = (command) => { const words = wordsOf(command); return sessionPrograms.includes(base(words[0])) || sessionPrograms.includes(base(scriptOf(command))); };
213
+ const parentless = (row) => { const parent = table.get(row.ppid); return row.ppid === 1 || !parent || base(wordsOf(parent.command)[0]) === 'systemd'; };
214
+ const above = (row) => { const chain = []; for (let at = table.get(row.ppid), n = 0; at && n < 64; at = table.get(at.ppid), n++) chain.push(at); return chain; };
215
+ const byPid = new Map(running.map((row) => [row.pid, row]));
216
+ const kind = new Map();
217
+ for (const row of running) {
218
+ if (isPane(row.command) || row.tty || above(row).some((p) => isHarness(p.command) || isPane(p.command))) kind.set(row.pid, 'helper');
219
+ else if (parentless(row) && !labels.has(row.pid) && row.pid !== daemonPid && row.startedAt && now - Date.parse(row.startedAt) > orphanMin * 60_000) kind.set(row.pid, 'orphan');
220
+ else if (row.stale) kind.set(row.pid, 'service');
221
+ }
222
+ const of = (name) => running.filter((row) => kind.get(row.pid) === name);
223
+ const helpers = of('helper').filter((row) => row.stale);
224
+ const orphans = of('orphan').map((row) => ({ pid: row.pid, what: doing(row.command), install: installRoot(row.file), startedAt: row.startedAt, runningMs: now - Date.parse(row.startedAt), stale: row.stale }));
225
+ const services = new Map();
226
+ for (const row of of('service')) {
227
+ let top = row;
228
+ while (kind.get(top.ppid) === 'service') top = byPid.get(top.ppid);
229
+ const parent = table.get(top.ppid);
230
+ const label = labels.get(top.pid) ?? above(top).map((p) => labels.get(p.pid)).find(Boolean) ?? null;
231
+ const service = services.get(top.pid) ?? { pids: [], what: doing(top.command), owner: [labels.has(top.pid) || !parent || top.ppid === 1 ? null : named(parent.command), label].filter(Boolean).join(', ') || 'no parent and no service manager', install: installRoot(top.file), startedAt: top.startedAt, replacedAt: null };
232
+ service.pids.push(row.pid);
233
+ const known = [row.installedAt, remembered[`${row.pid}:${row.startedAt}`]].filter(Boolean).sort()[0];
234
+ if (!service.replacedAt || known < service.replacedAt) service.replacedAt = known;
235
+ services.set(top.pid, service);
236
+ }
237
+ const listed = [...services.values()].map((service) => ({ ...service, outlivedMs: now - Date.parse(service.replacedAt) })).sort((a, b) => a.replacedAt.localeCompare(b.replacedAt));
238
+ // what the next reading remembers: every service process's earliest known replacement
239
+ const remember = {};
240
+ for (const row of of('service')) { let top = row; while (kind.get(top.ppid) === 'service') top = byPid.get(top.ppid); remember[`${row.pid}:${row.startedAt}`] = services.get(top.pid).replacedAt; }
241
+ return {
242
+ helpers: { count: helpers.length, oldest_start: helpers.map((row) => row.startedAt).sort()[0] ?? null, by: tally(helpers.map((row) => doing(row.command))) },
243
+ orphans, services: listed, remember,
244
+ };
245
+ }
246
+
247
+ const tally = (names) => { const counts = {}; for (const name of names) counts[name] = (counts[name] ?? 0) + 1; return counts; };
248
+ /** The install a file is in: the folder that holds the `node_modules` its package was put in. */
249
+ const installRoot = (file) => { const at = file.indexOf('/node_modules/@volter/'); return at > 0 ? file.slice(0, at) : dirname(file); };
package/lib/mail.mjs CHANGED
@@ -59,6 +59,33 @@ export function daemonCall(method, params, { env = process.env, timeoutMs = 15_0
59
59
  });
60
60
  }
61
61
 
62
+ /**
63
+ * Whether this machine's daemon answers its local socket, asked from outside it: a connection and the daemon's hello.
64
+ * `{ state: 'answered', ms }`, `{ state: 'silent', connected, ms }` (nothing came back within `timeoutMs`; a socket
65
+ * whose process is alive and serving nothing still takes the connection, since the kernel holds the queue), or
66
+ * `{ state: 'absent', error }` (nothing listens there). No request is made, so it reads the daemon's event loop alone.
67
+ */
68
+ export function daemonHello({ env = process.env, timeoutMs = 6_000 } = {}) {
69
+ return new Promise((resolve) => {
70
+ const started = performance.now(), socket = connect(daemonSocket(env));
71
+ let connected = false, buffer = Buffer.alloc(0);
72
+ const done = (value) => { clearTimeout(timer); socket.destroy(); resolve({ ...value, ms: Math.round(performance.now() - started) }); };
73
+ const timer = setTimeout(() => done({ state: 'silent', connected }), timeoutMs);
74
+ socket.on('error', (error) => done(connected ? { state: 'silent', connected, error: error.message } : { state: 'absent', error: error.code ?? error.message }));
75
+ socket.on('close', () => done({ state: 'silent', connected, error: 'the socket closed before the daemon answered' }));
76
+ socket.on('connect', () => {
77
+ connected = true;
78
+ const bytes = Buffer.from(JSON.stringify({ p: 'supercode-machine/1', nv: 3 })), head = Buffer.alloc(4);
79
+ head.writeUInt32BE(bytes.length, 0);
80
+ socket.write(Buffer.concat([head, bytes]));
81
+ });
82
+ socket.on('data', (chunk) => {
83
+ buffer = Buffer.concat([buffer, chunk]);
84
+ if (buffer.length >= 4 && buffer.length >= 4 + buffer.readUInt32BE(0)) done({ state: 'answered' });
85
+ });
86
+ });
87
+ }
88
+
62
89
  /** File one alarm mail for each address. Answers what was filed and the first failure, if any. */
63
90
  export async function fileAlarm({ machine, env = process.env, to, key, subject, body }) {
64
91
  const filed = [];
package/lib/promises.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The core promises, read live (promises.json is the list). One pass reads every promise at once, each inside its own
2
2
  // hard bound, and says of each `true`, `false` or `unknown` with the reading behind it and the milliseconds it took.
3
3
  // A pass changes nothing: it reads files, this machine's daemon socket, two process listings, the server's machine
4
- // list and the installed release's own restart check. Where another program has to be started the reason is beside
4
+ // list, the installed release's own restart check and, on the install's host, two public documents of the npm registry. Where another program has to be started the reason is beside
5
5
  // it; nothing here starts a shell to read what a file or a socket answers. No reading carries an environment's values,
6
6
  // a credential or a message's text.
7
7
  //
@@ -15,9 +15,9 @@ import { homedir, tmpdir, userInfo } from 'node:os';
15
15
  import { delimiter, isAbsolute, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { run } from './run.mjs';
18
- import { daemonCall } from './mail.mjs';
18
+ import { daemonCall, daemonHello } from './mail.mjs';
19
19
  import { healthDir, machineName } from './paths.mjs';
20
- import { staleSupercode } from './drift.mjs';
20
+ import { supercodeProcesses, classifyReleaseProcesses } from './drift.mjs';
21
21
  import { harnessMarks } from './markers.mjs';
22
22
 
23
23
  const json = (file) => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } };
@@ -37,10 +37,18 @@ export function promiseList() {
37
37
  export const PROMISE_DEFAULTS = Object.freeze({
38
38
  mailExpiryMin: 15, // mail that expired undelivered this recently says delivery is failing now
39
39
  staleReleaseHours: 2, // extrapolated: a service restarts with its install; two hours is past any restart in hand
40
+ stuckCommandMin: 30, // extrapolated: no supercode command is known to take this long once its caller is gone (the doors a command waits on are bounded in seconds)
40
41
  boardRoundMin: 10, // extrapolated from a round's own length (about 30 s here) and the maintainer's 5 min line
41
42
  diskFreeGB: 5, // extrapolated: admission's copy of an 800 MB mailbox and an install both need room
42
43
  fleetSeenHours: 24, // a machine silent longer than this is taken to be off, not broken
44
+ releaseWaitMin: 30, // measured 2026-10-09/10 over 17 releases: published 2 to 16.5 min after the binary was built, a whole run 16 to 39 min; twice the longest wait
45
+ releaseReadMin: 5, // the registry is asked at most this often by one probe
46
+ releaseWatch: 'board', // where it is read: 'board' (a machine whose board dispatcher ran in the last 24 h), true, false
47
+ releasePackage: '@volter/supercode',
48
+ releaseRegistry: 'https://registry.npmjs.org',
43
49
  harnesses: ['codex', 'claude'],
50
+ sessionPrograms: ['claude', 'codex', 'gemini', 'grok', 'goose', 'opencode', 'openclaw'], // a process under one of these lives as long as its session
51
+ daemonAnswerMs: 6000, // extrapolated: above the longest step the daemon is known to wait on in one call (a 5 s child)
44
52
  });
45
53
 
46
54
  /** The server's machine list, asked once a pass and shared by the two promises that read it. The server alone can say
@@ -71,6 +79,72 @@ function installs(name, search) {
71
79
  return found;
72
80
  }
73
81
 
82
+ /** Each service process's earliest known replacement (`pid:startedAt` → ISO time), as this process has read it. */
83
+ const replacedSeen = new Map();
84
+
85
+ /** The supercode processes by what replacing a release asks of them, read once a pass and shared by the two promises
86
+ * that read it. What an earlier pass knew of a service's replacement comes from this process and from the probe's
87
+ * snapshot, so it holds across a probe's restart and for a pass taken by hand. */
88
+ function releaseProcesses(context) {
89
+ context.processes ??= supercodeProcesses().then((reading) => {
90
+ const kept = json(join(healthDir(context.env), 'latest.json'))?.readings?.promises?.items?.find((item) => item.name === 'no-stale-release')?.detail?.remember ?? {};
91
+ const remembered = { ...kept };
92
+ for (const [key, at] of replacedSeen) if (!(remembered[key] <= at)) remembered[key] = at;
93
+ const daemonPid = json(join(teamsHome(context.env), 'machine.json'))?.pid ?? null;
94
+ const sorted = classifyReleaseProcesses(reading, { now: context.now, sessionPrograms: context.lines.sessionPrograms, daemonPid, orphanMin: context.lines.stuckCommandMin, remembered });
95
+ replacedSeen.clear();
96
+ for (const [key, at] of Object.entries(sorted.remember)) replacedSeen.set(key, at);
97
+ return sorted;
98
+ });
99
+ return context.processes;
100
+ }
101
+
102
+ /** `a` is a later release than `b` (plain `x.y.z`; a prerelease is read by its numbers). */
103
+ const laterVersion = (a, b) => { const [x, y] = [a, b].map((v) => String(v).split('-')[0].split('.').map(Number)); for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i]; return false; };
104
+
105
+ /**
106
+ * What the registry shows of a release, judged: `latest` is the version `npm install` of the release's package takes
107
+ * (the meta package's `latest`; the release workflow publishes it last, only when its checks passed); `built` is the
108
+ * publish time of each version of the release's binary package (the workflow publishes it under `candidate` once the
109
+ * build is done, before the checks). A built version later than `latest` is a release that has not reached anyone:
110
+ * its run is at its checks, or failed there, or stopped. False once the oldest such has waited `waitMin`; measured
111
+ * from the oldest, so a second failed release behind it does not start the wait again.
112
+ */
113
+ export function releaseReading({ name, latest, built, now, waitMin }) {
114
+ const waiting = Object.entries(built ?? {}).filter(([version, at]) => /^\d+\.\d+\.\d+/u.test(version) && typeof at === 'string' && laterVersion(version, latest)).map(([version, at]) => ({ version, built_at: at })).sort((a, b) => a.built_at.localeCompare(b.built_at));
115
+ const detail = { package: name, latest, waiting, wait_min: waitMin };
116
+ if (!waiting.length) return yes(`${name}@${latest} is what installs, and no later release is built and waiting`, detail);
117
+ const since = now - Date.parse(waiting[0].built_at), first = waiting[0], behind = waiting.slice(1).map((w) => w.version);
118
+ return since > waitMin * 60_000
119
+ ? no(`${waiting.length} release(s) built and not published: ${waiting.map((w) => w.version).join(', ')}. ${first.version} was built at ${first.built_at}, ${ago(since)} ago (the line is ${waitMin} min: a release that passes its checks is published 2 to 17 min after its build); ${name}@${latest} is still what installs. Its release run failed after the build or is stopped: read the run (gh run list --workflow release.yml)`, detail)
120
+ : yes(`${name}@${latest} is what installs; ${first.version} was built ${ago(since)} ago and is not published yet${behind.length ? `, with ${behind.join(', ')} behind it` : ''} (the line is ${waitMin} min)`, detail);
121
+ }
122
+
123
+ /** A registry document, read at most once in `everyMs` by this process and asked again with the tag it came with (an
124
+ * unchanged document answers with no body). No credential is sent: a registry that wants one is not read. */
125
+ const registryRead = new Map();
126
+ async function registryJson(url, { everyMs, timeoutMs, now = Date.now() }) {
127
+ const had = registryRead.get(url);
128
+ if (had && now - had.at < everyMs) return had.body;
129
+ const answer = await fetch(url, { headers: { accept: 'application/json', ...(had?.etag ? { 'if-none-match': had.etag } : {}) }, signal: AbortSignal.timeout(timeoutMs) })
130
+ .catch((error) => { throw new Error(`${new URL(url).host} did not answer within ${timeoutMs / 1000} s (${error.cause?.code ?? error.name})`); });
131
+ if (answer.status === 304 && had) { had.at = now; return had.body; }
132
+ if (!answer.ok) throw new Error(`${new URL(url).host} answered ${answer.status} for ${decodeURIComponent(new URL(url).pathname.slice(1))}`);
133
+ const body = await answer.json();
134
+ registryRead.set(url, { at: now, etag: answer.headers.get('etag'), body });
135
+ return body;
136
+ }
137
+
138
+ /** The board homes of this machine where a dispatcher ended a round in the last `hours`: the install's host is the
139
+ * machine that runs its board. */
140
+ function dispatcherHomes(context, hours = 24) {
141
+ const held = json(join(supercodeHome(context.env), 'board-homes.json')) ?? {};
142
+ return [...new Set(Object.values(held).filter((value) => typeof value === 'string'))].filter((root) => {
143
+ const at = Date.parse(json(join(root, 'dispatcher-heartbeat.json'))?.at ?? '');
144
+ return context.now - at <= hours * 3_600_000;
145
+ });
146
+ }
147
+
74
148
  const PROBES = {
75
149
  async server(context) {
76
150
  const items = await machineList(context);
@@ -117,6 +191,27 @@ const PROBES = {
117
191
  : yes(`the daemon (pid ${record.pid}) serves mail; none expired undelivered in the last ${context.lines.mailExpiryMin} min`, detail);
118
192
  },
119
193
 
194
+ // The daemon is one event loop: one call the operating system does not return from stops its heartbeat, its link,
195
+ // its socket and its mail at once, with the process alive, so nothing that watches for an exit sees it. This probe
196
+ // is a process of its own and asks the socket from outside. A daemon that is not running at all is not this
197
+ // promise's to say (`server-sees-machine` reads false for it, and a restart passes through that state): unknown.
198
+ async socket(context) {
199
+ const record = json(join(teamsHome(context.env), 'machine.json'));
200
+ if (!record?.pid) return unknown('no machine daemon has a record on this machine');
201
+ const bound = context.lines.daemonAnswerMs;
202
+ const answer = await daemonHello({ env: context.env, timeoutMs: bound });
203
+ let alive = true; try { process.kill(record.pid, 0); } catch (error) { alive = error.code === 'EPERM'; }
204
+ const detail = { pid: record.pid, alive, state: answer.state, connected: answer.connected ?? null, ms: answer.ms, error: answer.error ?? null };
205
+ if (answer.state === 'answered') return yes(`the daemon (pid ${record.pid}) answered its socket in ${answer.ms} ms`, detail);
206
+ if (!alive) return unknown(`the daemon's recorded process (pid ${record.pid}) is not running`);
207
+ // said where it is read: the daemon carries this machine's mail, so this alarm waits in the health snapshot and
208
+ // log, and is filed when the daemon answers again
209
+ const route = 'while it does not answer it sends no heartbeat and files no mail, this alarm included: it stands in this machine\'s health snapshot and log until the daemon answers or its service is restarted';
210
+ return answer.state === 'silent'
211
+ ? no(`the daemon's process (pid ${record.pid}) is alive and ${answer.connected ? 'took a connection on its socket but did not answer it' : 'did not take a connection on its socket'} within ${bound / 1000} s; ${route}`, detail)
212
+ : no(`the daemon's process (pid ${record.pid}) is alive and nothing listens on its socket (${answer.error}); ${route}`, detail);
213
+ },
214
+
120
215
  // The login shell is started because only it can say its PATH (its profile builds it), and each install is asked its
121
216
  // own version because only it knows: the two things a launch does. Codex's background server names its version in
122
217
  // the folder it runs from, which is read from the process list.
@@ -155,22 +250,53 @@ const PROBES = {
155
250
  : yes('no terminal window or tmux server carries a child-session mark');
156
251
  },
157
252
 
253
+ // What replacing a release asks of a process depends on who started it (drift.mjs classifyReleaseProcesses). A
254
+ // session's own helpers (its mail MCP server, its Codex pane's wrapper) live as long as the session and are counted;
255
+ // a service is what an install should have restarted, and is the promise. How long a service has outlived its
256
+ // release is measured from the earliest reading that found its file replaced, kept from pass to pass (this process's
257
+ // memory and the last pass in the probe's snapshot): the file's own time moves with every later install, and a
258
+ // promise measured from it read true again each time another release was installed.
158
259
  async stale(context) {
159
260
  const line = context.lines.staleReleaseHours * 3_600_000;
160
- const stale = await staleSupercode();
161
- const over = stale.filter((row) => context.now - Date.parse(row.installedAt) > line);
162
- // what each runs, for the name: its program's last two path parts and its verb, never its arguments
163
- const named = new Map();
164
- for (const row of over) {
165
- const words = row.command.split(/\s+/u), node = /(^|\/)node$/u.test(words[0]);
166
- const program = (node ? words[1] : words[0]).split('/').slice(-2).join('/');
167
- const verb = words.slice(node ? 2 : 1).filter((word) => /^[a-z][a-z-]*$/u.test(word)).slice(0, 2).join(' ');
168
- const key = verb ? `${program} ${verb}` : program;
169
- named.set(key, (named.get(key) ?? 0) + 1);
261
+ const { helpers, services, orphans, remember } = await releaseProcesses(context);
262
+ const over = services.filter((service) => service.outlivedMs > line);
263
+ const say = (service) => `${service.what} (pid ${service.pids.join(', ')}) of ${service.owner}, from the install at ${service.install}, started ${service.startedAt}, its release replaced at least ${ago(service.outlivedMs)} ago`;
264
+ const beside = [
265
+ services.length - over.length ? `${services.length - over.length} service(s) run a release replaced in the last ${context.lines.staleReleaseHours} h` : null,
266
+ helpers.count ? `${helpers.count} session helper(s) run a replaced release and will until their sessions end (${Object.entries(helpers.by).sort((a, b) => b[1] - a[1]).map(([what, count]) => `${count} ${what}`).join(', ')}; the oldest started ${helpers.oldest_start}): not this promise's` : null,
267
+ ].filter(Boolean).join('; ');
268
+ const detail = { services, helpers, stuck: orphans.length, remember };
269
+ return over.length ? no(`${over.length} service(s) run a release replaced more than ${context.lines.staleReleaseHours} h ago: ${over.map(say).join('; ')}${beside ? `. Beside them: ${beside}` : ''}`, detail)
270
+ : yes(`no service has outlived its release by ${context.lines.staleReleaseHours} h${beside ? `; ${beside}` : ''}`, detail);
271
+ },
272
+
273
+ // A command whose caller is gone and that still runs: nothing reads its answer and nothing ends it (2026-10-10: a
274
+ // `teams files put` ran three hours under the system's first process, waiting on a remote daemon that had stopped
275
+ // answering). It is not a stale release, whichever release it runs, and is said here by itself.
276
+ async stuck(context) {
277
+ const { orphans } = await releaseProcesses(context);
278
+ return orphans.length ? no(`${orphans.length} supercode command(s) have run more than ${context.lines.stuckCommandMin} min with no parent and no service manager: ${orphans.map((o) => `${o.what} (pid ${o.pid}, from ${o.install}, started ${o.startedAt}, ${ago(o.runningMs)} ago)`).join('; ')}`, orphans)
279
+ : yes(`no supercode command has run more than ${context.lines.stuckCommandMin} min without a parent`);
280
+ },
281
+
282
+ // Two public documents of the npm registry, no credential: the meta package's dist-tags (what installs) and the
283
+ // binary package's document for this platform (when each version was built). Read where the install's board runs,
284
+ // unless the config says (`releaseWatch`: true, false, or 'board'): one failed release is one alarm, not one a
285
+ // machine. Unknown when the registry does not answer; never false for that.
286
+ async release(context) {
287
+ const lines = context.lines;
288
+ if (lines.releaseWatch === false || (lines.releaseWatch !== true && !dispatcherHomes(context).length)) {
289
+ return yes(`not read on this machine: ${lines.releaseWatch === false ? 'its config turns it off' : 'no board dispatcher ran a round here in the last 24 h, and the install\'s host reads it'}`);
170
290
  }
171
- const detail = { stale: stale.length, over: over.length, by: Object.fromEntries(named), oldest_start: over.map((row) => row.startedAt).sort()[0] ?? null, pids: over.slice(0, 40).map((row) => row.pid) };
172
- return over.length ? no(`${over.length} process(es) still run a release replaced more than ${context.lines.staleReleaseHours} h ago: ${[...named].sort((a, b) => b[1] - a[1]).map(([what, count]) => `${count} ${what}`).join(', ')}; the oldest started ${detail.oldest_start}`, detail)
173
- : yes(stale.length ? `${stale.length} process(es) run a release replaced in the last ${context.lines.staleReleaseHours} h, none longer` : 'every supercode process runs the release on disk', detail);
291
+ const registry = String(lines.releaseRegistry).replace(/\/+$/u, ''), name = lines.releasePackage;
292
+ const binary = `${name}-cli-${process.platform}-${process.arch}`;
293
+ const every = { everyMs: lines.releaseReadMin * 60_000, timeoutMs: 4000, now: context.now };
294
+ const [tags, built] = await Promise.all([
295
+ registryJson(`${registry}/-/package/${name.replace('/', '%2f')}/dist-tags`, every),
296
+ registryJson(`${registry}/${binary.replace('/', '%2f')}`, every),
297
+ ]);
298
+ if (typeof tags?.latest !== 'string' || !built?.time) return unknown(`${registry} answered no latest for ${name} or no publish times for ${binary}`);
299
+ return releaseReading({ name, latest: tags.latest, built: built.time, now: context.now, waitMin: lines.releaseWaitMin });
174
300
  },
175
301
 
176
302
  async board(context) {
@@ -225,7 +351,7 @@ const PROBES = {
225
351
  */
226
352
  export async function readPromises({ env = process.env, config = {}, machine = null, only = null, now = Date.now() } = {}) {
227
353
  const started = performance.now();
228
- const context = { env, now, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null };
354
+ const context = { env, now, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null, processes: null };
229
355
  const list = promiseList().promises.filter((entry) => !only || only.includes(entry.name));
230
356
  const items = await Promise.all(list.map(async (entry) => {
231
357
  const t0 = performance.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-health",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "type": "module",
5
5
  "description": "Optional machine pack: a cheap, deterministic probe of a machine's vitals that mails the machine's maintainer when a threshold is crossed. Each feature sits behind its own permission barrier.",
6
6
  "license": "MIT",
package/promises.json CHANGED
@@ -8,18 +8,24 @@
8
8
  "promise": "A restart of this machine's daemon would not be refused for state already standing here (the runtime, the pane wrappers, the Maildir against its retirement receipt)." },
9
9
  { "name": "mail-serves", "probe": "mail", "bound_ms": 1500, "activation": "report",
10
10
  "promise": "The daemon answers on its socket, serves mail, and no mail expired undelivered in the last 15 minutes." },
11
+ { "name": "daemon-answers", "probe": "socket", "bound_ms": 6500, "activation": "report",
12
+ "promise": "The daemon's running process answers its local socket within 6 seconds, asked by the health probe's own process: it serves, not only lives." },
11
13
  { "name": "harness-one-install", "probe": "harness", "bound_ms": 4000, "activation": "report",
12
14
  "promise": "Each harness CLI on this machine (codex, claude) is one version wherever a launch could find it (the login shell's PATH, then the daemon's), and Codex's background server runs that version." },
13
15
  { "name": "no-harness-marks", "probe": "marks", "bound_ms": 2000, "activation": "report",
14
16
  "promise": "No terminal window or tmux server carries a harness's child-session marks, so a session a person starts in one is its own session." },
15
17
  { "name": "no-stale-release", "probe": "stale", "bound_ms": 3000, "activation": "report",
16
- "promise": "No supercode process has run a release for more than two hours after an install replaced it." },
18
+ "promise": "No service has run a supercode release for more than two hours after its own install replaced it: the daemon and its children, a board keeper's, another product's. A session's own helpers (its mail MCP server, its Codex pane's wrapper) live as long as the session and are counted, not held to this." },
19
+ { "name": "no-stuck-command", "probe": "stuck", "bound_ms": 3000, "activation": "report",
20
+ "promise": "No supercode command has run more than 30 minutes with no parent and no service manager: one that has is stuck, with nothing reading its answer." },
17
21
  { "name": "board-round", "probe": "board", "bound_ms": 500, "activation": "report",
18
22
  "promise": "Where a board's dispatcher runs, its last round ended in the last ten minutes with nothing failed." },
19
23
  { "name": "disk-free", "probe": "disk", "bound_ms": 500, "activation": "report",
20
24
  "promise": "The volumes supercode's home, the person's home and the temporary folder are on each have more than 5 GB free." },
21
25
  { "name": "release-kept", "probe": "activation", "bound_ms": 200, "activation": "report",
22
26
  "promise": "The last release activated on this machine was kept: it was not put back, and it broke no promise that held before it." },
27
+ { "name": "release-published", "probe": "release", "bound_ms": 5000, "activation": "report",
28
+ "promise": "What main last tagged for release is published: no release has been built and waiting more than 30 minutes for the version npm installs to reach it. Read on the install's host, from the public registry." },
23
29
  { "name": "fleet-online", "probe": "fleet", "bound_ms": 3000, "activation": "report",
24
30
  "promise": "Every other machine enrolled with this server and heard from in the last 24 hours is online now." }
25
31
  ]