@volter/supercode-health 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -32,10 +32,15 @@ a minute, 24 h) and `probe.log` to `$SUPERCODE_HOME/health`, which defaults to
32
32
 
33
33
  ## Alarms
34
34
 
35
- `supercode health subscribe sc:<machine>:<harness>:<id>` subscribes a session or board.
36
- Each raise, escalation and clear mails the subscribers. A still-raised alarm mails again
37
- every `repeatMin` (60). Mail comes from `sc:<machine>:operator:machine-health` through
38
- `supercode message remote-deliver`. Undelivered mail is kept and retried for a day.
35
+ `supercode health subscribe sc:<machine>:agent:<name>` subscribes an agent (a maintainer:
36
+ the mailbox delivers to its main session as it is then, so a relaunched main still gets
37
+ it); a session or board address may be subscribed too, and its mail stops reaching it
38
+ when the session ends. Each raise, escalation and clear mails the subscribers. A
39
+ still-raised alarm mails again every `repeatMin` (60). Mail comes from
40
+ `sc:<machine>:operator:machine-health`, filed in each subscriber's mailbox
41
+ (`supercode message file`) after the cycle's reading is written; the mailbox delivers
42
+ it, within its bounds, and `supercode message waiting` / `supercode message expired` list
43
+ what it has not delivered.
39
44
 
40
45
  ## Config
41
46
 
package/bin/health.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // status [--json] [--all] this machine's latest reading (latest.json)
8
8
  // features [--json] every feature, on or off, its permission barrier and why
9
9
  // alarms [--json] the alarms raised now
10
- // subscribe <address> | unsubscribe <address> alarm mail for a session or board address
10
+ // subscribe <address> | unsubscribe <address> alarm mail for an agent (sc:<machine>:agent:<name>), session or board
11
11
  // config where the config lives and what it currently resolves to
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { parseArgs } from 'node:util';
package/lib/darwin.mjs CHANGED
@@ -156,14 +156,20 @@ export async function diskIO() {
156
156
  }
157
157
 
158
158
  /** The user's launchd jobs: label → { pid, lastExit }. */
159
- export async function services() {
160
- const r = await run('/bin/launchctl', ['list'], { timeoutMs: 5000 });
161
- if (!r.ok) throw new Error(r.error);
159
+ /** The user's launchd jobs: label → { pid, lastExit, runs }. `runs` counts every spawn since the job was loaded,
160
+ * so a KeepAlive job that crashes and respawns between two readings shows as runs rising with a failed last exit,
161
+ * where `launchctl list` alone shows a pid and looks running (12 ms per `launchctl print`). */
162
+ export async function services(labels = []) {
162
163
  const map = new Map();
163
- for (const line of r.stdout.split('\n').slice(1)) {
164
- const [pid, status, label] = line.split('\t');
165
- if (label) map.set(label, { pid: pid === '-' ? null : Number(pid), lastExit: Number(status) });
166
- }
164
+ const uid = process.getuid?.();
165
+ await Promise.all(labels.map(async (label) => {
166
+ const r = await run('/bin/launchctl', ['print', `gui/${uid}/${label}`], { timeoutMs: 5000 });
167
+ if (!r.ok) return; // not loaded
168
+ const field = (name) => new RegExp(`^\\t${name} = (.+)$`, 'm').exec(r.stdout)?.[1];
169
+ const pid = Number(field('pid')) || null;
170
+ const lastExit = field('last exit code');
171
+ map.set(label, { pid, lastExit: lastExit == null || /never exited/.test(lastExit) ? 0 : Number.parseInt(lastExit, 10), runs: Number(field('runs')) || 0 });
172
+ }));
167
173
  return map;
168
174
  }
169
175
 
package/lib/format.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // The snapshot as a person reads it: alarms first, then each reading, then which features are off and why.
2
2
  const n = (v, unit = '') => (v == null ? '-' : `${v}${unit}`);
3
- const who = (p) => (p?.session?.address ? ` ← ${p.session.address}` : p?.session?.harness ? ` ← ${p.session.harness} pid ${p.session.agentPid}` : '');
3
+ const who = (p) => (p?.session?.address ? ` ← ${p.session.address}` : p?.session?.harness ? ` ← ${p.session.harness} pid ${p.session.agentPid}` : p?.session?.note ? ` ← (${p.session.note})` : p?.session?.inherited ? ` ← (no agent above; inherited ${p.session.inherited})` : '');
4
4
 
5
5
  export function formatStatus(s, { all = false } = {}) {
6
6
  if (!s) return 'machine health: no reading yet (is the probe running? `supercode health run`)';
@@ -10,7 +10,7 @@ export function formatStatus(s, { all = false } = {}) {
10
10
  if (age > 3 * (s.probe?.intervalSec ?? 15)) out.push(` STALE: the probe has not written for ${age}s; its readings below are old`);
11
11
  out.push('', s.alarms.length ? `ALARMS (${s.alarms.length})` : 'ALARMS: none');
12
12
  for (const a of s.alarms) out.push(` ${a.level.toUpperCase().padEnd(4)} ${a.summary}${a.detail?.pid ? ` [${a.detail.command?.slice(0, 80)}${who(a.detail)}]` : ''} (since ${a.since})`);
13
- if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.pending ? ` · ${s.mail.pending} undelivered: ${s.mail.error}` : ''}`);
13
+ if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.error ? ` · last mail not filed: ${s.mail.error}` : s.mail?.filed ? ` · last mail filed ${s.mail.at} (supercode message waiting lists any not yet delivered)` : ''}`);
14
14
  if (r.load) out.push('', `load ${r.load.one} / ${r.load.five} / ${r.load.fifteen} on ${r.load.cores} cores (${r.load.perCore}× per core)`);
15
15
  if (r.memory) {
16
16
  const m = r.memory;
@@ -25,6 +25,10 @@ export function formatStatus(s, { all = false } = {}) {
25
25
  if (r.renderers) {
26
26
  out.push(`renderers ${r.renderers.count} using ${r.renderers.totalMB} MB${r.renderers.orphanedHeadless.length ? `; ${r.renderers.orphanedHeadless.length} orphaned headless browser(s): ${r.renderers.orphanedHeadless.map((p) => p.pid).join(', ')}` : ''}`);
27
27
  for (const p of r.renderers.top.slice(0, 3)) out.push(` ${String(p.cpuPct ?? '-').padStart(4)}% ${String(p.footprintMB).padStart(6)} MB ${p.pid}${who(p)}${p.tab ? ` · ${p.tab.url.slice(0, 60)} · ${p.tab.owner}${who(p.tab.server)}` : ' · tab not matched'}`);
28
+ // Chrome names no renderer per tab (CDP's TargetInfo has no pid): a renderer that holds no storage bucket
29
+ // cannot be matched, so the tabs no renderer was matched to are listed with their owners instead.
30
+ const unmatched = (r.browserTabs?.tabs ?? []).filter((t) => !t.renderers.length);
31
+ if (r.renderers.top.slice(0, 3).some((p) => !p.tab) && unmatched.length) out.push(` unmatched tabs (one may be served by an unmatched renderer above): ${unmatched.map((t) => `${t.url.slice(0, 50)} · ${t.owner}${who(t.server)}`).join(' | ')}`);
28
32
  }
29
33
  if (r.browserTabs) {
30
34
  out.push(`tabs ${r.browserTabs.count} open${Object.keys(r.browserTabs.byHolder).length ? ` (${Object.entries(r.browserTabs.byHolder).map(([h, c]) => `${h} ${c}`).join(', ')})` : ''}`);
@@ -41,7 +45,7 @@ export function formatStatus(s, { all = false } = {}) {
41
45
  for (const g of t.timeWaitByTarget.slice(0, 3)) out.push(` TIME_WAIT ${g.count} (${g.pct}%) against ${g.target}${g.listener ? ` · pid ${g.listener.pid} ${g.listener.command.slice(0, 60)}${who(g.listener)}` : ''}`);
42
46
  }
43
47
  if (r.files) out.push(`files ${r.files.open} of ${r.files.max} open (${r.files.pct}%)`);
44
- if (r.services) for (const sv of r.services) out.push(`service ${sv.name ?? sv.label}: ${sv.running ? `running pid ${sv.pid}` : `NOT running (last exit ${sv.lastExit})`}${sv.logAgeMin != null ? ` · log written ${sv.logAgeMin} min ago` : ''}`);
48
+ if (r.services) for (const sv of r.services) out.push(`service ${sv.name ?? sv.label}: ${sv.crashing ? `CRASH-LOOPING: ${sv.restarts} restarts since the last reading, last exit ${sv.lastExit}, ${sv.runs} runs` : sv.running ? `running pid ${sv.pid}` : `NOT running (last exit ${sv.lastExit})`}${sv.logAgeMin != null ? ` · log written ${sv.logAgeMin} min ago` : ''}`);
45
49
  if (r.supercode) {
46
50
  const c = r.supercode;
47
51
  out.push(`supercode connector ${c.connector ? `${c.connector.running ? 'running' : 'NOT running'}, server sees ${c.connector.connection ?? '-'}${c.connector.notSyncing ? `, ${c.connector.notSyncing} sessions not syncing (${c.connector.lastError})` : ''}` : (c.statusError ?? 'unknown')} · daemon ${c.daemon.answered ? `answered in ${c.daemon.ms} ms` : `NOT answering (${c.daemon.error})`} · launches ${c.launches.read ? `${c.launches.recent} in 24 h, ${c.launches.stuck.length} stuck, ${c.launches.failed.length} failed` : `unread (${c.launches.error})`}`);
package/lib/linux.mjs CHANGED
@@ -135,11 +135,11 @@ export async function diskIO() {
135
135
  export async function services(names = []) {
136
136
  const map = new Map();
137
137
  if (!names.length) return map;
138
- const r = await run('systemctl', ['--user', 'show', '-p', 'Id,MainPID,ExecMainStatus,ActiveState', ...names], { timeoutMs: 5000 });
138
+ const r = await run('systemctl', ['--user', 'show', '-p', 'Id,MainPID,ExecMainStatus,ActiveState,NRestarts', ...names], { timeoutMs: 5000 });
139
139
  if (!r.ok) throw new Error(r.error);
140
140
  for (const block of r.stdout.split('\n\n')) {
141
141
  const kv = Object.fromEntries(block.split('\n').filter(Boolean).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]));
142
- if (kv.Id) map.set(kv.Id.replace(/\.service$/, ''), { pid: Number(kv.MainPID) || null, lastExit: Number(kv.ExecMainStatus) || 0, active: kv.ActiveState });
142
+ if (kv.Id) map.set(kv.Id.replace(/\.service$/, ''), { pid: Number(kv.MainPID) || null, lastExit: Number(kv.ExecMainStatus) || 0, active: kv.ActiveState, runs: (Number(kv.NRestarts) || 0) + 1 });
143
143
  }
144
144
  return map;
145
145
  }
package/lib/mail.mjs CHANGED
@@ -1,50 +1,28 @@
1
- // Alarm mail through supercode's public mailbox door (`supercode message remote-deliver`), from
2
- // `sc:<machine>:operator:machine-health`. Undelivered mail stays in the outbox and is retried each cycle,
3
- // as the machine-events outbox does, until it is a day old.
4
- import { spawn } from 'node:child_process';
5
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
1
+ // Alarm mail, filed in each subscriber's mailbox through supercode's filing door (`supercode message file`), from
2
+ // `sc:<machine>:operator:machine-health`, and delivered by the mailbox (docs/adr/0008-agent-mailbox.md, decision 17;
3
+ // D138 step 6). Filing is a local write: no door, no network, and nothing for the probe to retry. The mailbox resolves
4
+ // an agent address (`sc:<machine>:agent:<name>`) to the agent's main session when it delivers, retries within its
5
+ // bounds, and records what it could not deliver (`supercode message waiting`, `supercode message expired`).
6
+ import { spawnSync } from 'node:child_process';
6
7
 
7
- export class Outbox {
8
- constructor({ file, machine, env = process.env }) {
9
- Object.assign(this, { file, machine, env });
10
- try { this.items = JSON.parse(readFileSync(file, 'utf8')); } catch { this.items = []; }
11
- this.error = null;
12
- }
13
- save() { const temp = `${this.file}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(this.items) + '\n', { mode: 0o600 }); renameSync(temp, this.file); }
14
- enqueue(to, id, subject, body) { this.items.push({ to, id, subject, body, at: Date.now(), tries: 0 }); this.save(); }
15
- async flush() {
16
- for (const item of [...this.items]) {
17
- if (Date.now() - item.at > 24 * 3_600_000) { this.items = this.items.filter((x) => x !== item); this.error = `dropped undelivered ${item.id} to ${item.to} after a day`; continue; }
18
- try { await this.deliver(item); this.items = this.items.filter((x) => x !== item); this.error = null; }
19
- catch (error) { item.tries += 1; this.error = `mail to ${item.to}: ${error.message}`; }
20
- }
21
- this.save();
22
- return { pending: this.items.length, error: this.error };
23
- }
24
- deliver(item) {
25
- return new Promise((resolve, reject) => {
26
- const child = spawn(this.env.SUPERCODE_BIN || 'supercode', ['message', 'remote-deliver'], { env: this.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32' });
27
- let detail = '';
28
- const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('delivery timed out; kept for retry')); }, 30_000);
29
- child.stdout.on('data', (c) => { detail = (detail + c).slice(-2000); });
30
- child.stderr.on('data', (c) => { detail = (detail + c).slice(-2000); });
31
- child.once('error', (error) => { clearTimeout(timer); reject(error); });
32
- child.once('close', (code) => {
33
- clearTimeout(timer);
34
- let receipt; try { receipt = JSON.parse(detail.trim().split('\n').at(-1)); } catch { /* none */ }
35
- if (code === 0 && receipt?.code === 0) resolve(receipt); else reject(new Error(receipt?.text || detail.trim().slice(-300) || `exit ${code}`));
36
- });
37
- child.stdin.on('error', () => {});
38
- const board = item.to.split(':')[2] === 'board';
39
- child.stdin.end(JSON.stringify({ op: 'send', from: `sc:${this.machine}:operator:machine-health`, from_name: 'Machine health', to: item.to, id: item.id, subject: item.subject, queue: board, body: item.body }));
40
- });
8
+ /** File one alarm mail for each address. Answers what was filed and the first failure, if any. */
9
+ export function fileAlarm({ machine, env = process.env, to, key, subject, body }) {
10
+ const filed = [];
11
+ let error = null;
12
+ for (const address of to) {
13
+ const request = { from: `sc:${machine}:operator:machine-health`, from_name: 'Machine health', to: address, id: `${key}-${address}`, subject, body, wake: true };
14
+ const done = spawnSync(env.SUPERCODE_BIN || 'supercode', ['message', 'file'], { env, input: JSON.stringify(request), encoding: 'utf8', timeout: 10_000, windowsHide: true, shell: process.platform === 'win32' });
15
+ let answer = null; try { answer = JSON.parse(String(done.stdout ?? '').trim().split('\n').at(-1)); } catch { /* none */ }
16
+ if (answer?.code === 0) filed.push({ to: address, id: answer.message_id });
17
+ else error ??= `mail to ${address} was not filed: ${answer?.text || String(done.stderr ?? '').trim().slice(-300) || done.error?.message || `exit ${done.status}`}`;
41
18
  }
19
+ return { filed, error };
42
20
  }
43
21
 
44
22
  const LEVEL = { warn: 'WARN', crit: 'CRIT', none: 'clear' };
45
23
  function line(t) {
46
24
  const d = t.detail;
47
- const who = d?.session?.address ? `; session ${d.session.address}` : d?.session?.harness ? `; under ${d.session.harness} pid ${d.session.agentPid}` : d?.pid ? '; no agent session above it' : '';
25
+ const who = d?.session?.address ? `; session ${d.session.address}` : d?.session?.harness ? `; under ${d.session.harness} pid ${d.session.agentPid}` : d?.session?.note ? `; ${d.session.note}` : d?.session?.inherited ? `; no agent above it (environment inherited from ${d.session.inherited}, unverified)` : d?.pid ? '; no agent session above it' : '';
48
26
  const proc = d?.pid ? ` [pid ${d.pid}: ${d.command}${who}]` : d?.listener ? ` [listener pid ${d.listener.pid}: ${d.listener.command}${d.listener.session?.address ? `; session ${d.listener.session.address}` : ''}]` : '';
49
27
  return `${LEVEL[t.to] ?? t.to}${t.from !== 'none' && t.to !== 'none' ? ` (was ${t.from})` : ''} ${t.key}: ${t.summary}${proc}`;
50
28
  }
package/lib/probe.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // The probe: one per OS user, holding an exclusive pidfile. Every cycle it samples, judges each alarm,
2
- // writes latest.json (what the doors read) and a minute history, and mails the maintainers on a change.
2
+ // writes latest.json (what the doors read) and a minute history, and then files mail for the maintainers on a change
3
+ // (the mailbox delivers it; nothing the mail does can hold latest.json back).
3
4
  // It decides nothing and changes nothing on the machine.
4
5
  import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
5
6
  import { hostname } from 'node:os';
@@ -7,7 +8,7 @@ import { healthDir, files as healthFiles, machineName } from './paths.mjs';
7
8
  import { DEFAULTS, merge, judge, raised } from './thresholds.mjs';
8
9
  import { resolveFeatures } from './features.mjs';
9
10
  import { Sampler } from './sample.mjs';
10
- import { Outbox, compose } from './mail.mjs';
11
+ import { fileAlarm, compose } from './mail.mjs';
11
12
  import { run } from './run.mjs';
12
13
 
13
14
  export async function platformReader(platform = process.platform) {
@@ -76,7 +77,10 @@ export class Probe {
76
77
  this.config = loadConfig(this.f.config);
77
78
  this.configMtime = this.#mtime(this.f.config);
78
79
  this.sampler = new Sampler({ reader: this.reader, platform: this.platform, machine: this.machine, env: this.env, config: this.config });
79
- this.outbox = new Outbox({ file: this.f.outbox, machine: this.machine, env: this.env });
80
+ // Alarm mail queued here by an earlier pack (outbox.json, retried per recipient until a day old) is not resent: a
81
+ // mail is a reading, superseded by the current one, and its recipients were often sessions that have ended. It is
82
+ // dropped, and every alarm raised now is mailed again at the first cycle, filed in the maintainers' mailboxes.
83
+ this.droppedOutbox = this.#dropOutbox();
80
84
  this.extras = {
81
85
  rusage: this.reader.rusageAvailable ? await this.reader.rusageAvailable() : false,
82
86
  supercode: (await run(this.env.SUPERCODE_BIN || 'supercode', ['--version'], { timeoutMs: 10_000, env: this.env })).ok,
@@ -84,7 +88,18 @@ export class Probe {
84
88
  const previous = readJson(this.f.latest, null);
85
89
  // Alarms raised before a restart stay raised (and are not re-mailed); their windows restart honestly.
86
90
  const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
87
- for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
91
+ for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: this.droppedOutbox ? 0 : Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
92
+ }
93
+
94
+ /** Remove an earlier pack's outbox; answers how many mails it held, logged by recipient. */
95
+ #dropOutbox() {
96
+ const items = readJson(this.f.outbox, null);
97
+ if (!Array.isArray(items)) return 0;
98
+ const counts = {};
99
+ for (const item of items) counts[item?.to ?? '?'] = (counts[item?.to ?? '?'] ?? 0) + 1;
100
+ rmSync(this.f.outbox, { force: true });
101
+ this.log(`dropped ${items.length} undelivered alarm mail(s) an earlier pack queued (superseded by the current reading; the alarms raised now are mailed again): ${Object.entries(counts).map(([to, n]) => `${n} to ${to}`).join(', ') || 'none'}`);
102
+ return items.length;
88
103
  }
89
104
 
90
105
  #mtime(file) { try { return statSync(file).mtimeMs; } catch { return null; } }
@@ -109,10 +124,8 @@ export class Probe {
109
124
  const mail = compose({ machine: this.machine, transitions, reminders, raised: alarms });
110
125
  if (mail) {
111
126
  for (const a of alarms) this.state[a.key].lastMailAt = now;
112
- for (const address of to) this.outbox.enqueue(address, `health-${this.machine}-${now}-${address.split(':').at(-1).slice(0, 12)}`, mail.subject, mail.body);
113
127
  this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
114
128
  }
115
- const delivery = this.outbox.items.length ? await this.outbox.flush() : { pending: 0, error: this.outbox.error };
116
129
  const snapshot = {
117
130
  version: 1, machine: this.machine, platform: this.platform, at: new Date(now).toISOString(),
118
131
  probe: { pid: process.pid, intervalSec: this.config.intervalSec, slowIntervalSec: this.config.slowIntervalSec, sampleMs: this.sampleMs, user: this.env.USER ?? null, root: this.reader.isRoot?.() ?? false, config: this.config.source },
@@ -120,10 +133,18 @@ export class Probe {
120
133
  alarms,
121
134
  transitions: transitions.map((t) => ({ key: t.key, from: t.from, to: t.to, summary: t.summary })),
122
135
  maintainers: to,
123
- mail: delivery,
136
+ mail: this.lastMail ?? null,
124
137
  readings,
125
138
  };
139
+ // The reading is written first; the mail is filed after it, and what the filing did is written beside it.
126
140
  writeAtomic(this.f.latest, snapshot);
141
+ if (mail && to.length) {
142
+ const result = fileAlarm({ machine: this.machine, env: this.env, to, key: `health-${this.machine}-${now}`, subject: mail.subject, body: mail.body });
143
+ this.lastMail = { at: new Date(now).toISOString(), filed: result.filed.length, to: result.filed.map((f) => f.to), error: result.error };
144
+ if (result.error) this.log(result.error);
145
+ snapshot.mail = this.lastMail;
146
+ writeAtomic(this.f.latest, snapshot);
147
+ }
127
148
  if (now - this.lastHistory >= 60_000) { this.#history(snapshot, now); this.lastHistory = now; }
128
149
  return snapshot;
129
150
  }
package/lib/sample.mjs CHANGED
@@ -18,6 +18,11 @@ const HARNESS = { claude: 'claude-code', codex: 'codex', gemini: 'gemini', goose
18
18
  const RENDERER = /Helper \(Renderer\)|--type=renderer/;
19
19
  const BROWSER_MAIN = /(Google Chrome|Chromium|chrome-headless-shell|chrome)(\s|$)/i;
20
20
  const LOOPBACK = /^(127\.|::1$|localhost$)/;
21
+ /** The session an agent names on its own command line: `--resume <id>`, `--session-id <id>`, `-r <id>`, `codex resume <id>`. */
22
+ function argvSession(command) {
23
+ return /(?:--resume|--session-id|\s-r)[=\s]+([0-9a-f]{8}-[0-9a-f-]{27,})/.exec(command)?.[1]
24
+ ?? /\bresume\s+([0-9a-zA-Z][0-9a-zA-Z-]{7,})/.exec(command)?.[1] ?? null;
25
+ }
21
26
  // The slow set's readings and alarm families, by the feature that produces them.
22
27
  const SLOW_FEATURE = { disk: 'disk', services: 'services', service: 'services', supercode: 'supercode', browserTabs: 'browser-tabs', tabs: 'browser-tabs', power: 'power' };
23
28
  // Every alarm family, by the feature that observes it.
@@ -32,6 +37,8 @@ export class Sampler {
32
37
  this.swapRing = []; // [[at, swapouts]] for the 30-minute sum
33
38
  this.diskRing = new Map(); // mount → [[at, freeBytes]] for the fill rate
34
39
  this.sessionCache = new Map(); // pid:command → session
40
+ this.serviceRuns = new Map(); // service label → spawn count at the last reading
41
+ this.recentListeners = new Map(); // port → when it was last seen listening (its TIME_WAITs outlive it)
35
42
  this.topFoot = { at: 0, map: new Map() };
36
43
  this.slow = { at: 0, readings: {}, observations: [], families: new Set(), errors: {} };
37
44
  this.slowStarted = 0;
@@ -110,8 +117,11 @@ export class Sampler {
110
117
  r.footprintSource = ru ? 'footprint' : top != null ? 'footprint (top)' : 'rss';
111
118
  r.writeBps = same && dt && ru && prev.written != null && ru.diskWrittenBytes >= prev.written ? (ru.diskWrittenBytes - prev.written) / dt : null;
112
119
  nextPrev.set(r.pid, { cpuSec: r.cpuSec, command: r.command, written: ru?.diskWrittenBytes ?? null });
113
- const hist = same ? (this.footHistory.get(r.pid) ?? []) : [];
114
- hist.push([now, r.footprintBytes]);
120
+ // Growth compares like with like: a footprint read through a different instrument than the last reading
121
+ // (another user's process alternates between top's 5-minute map and RSS) starts a new history.
122
+ let hist = same ? (this.footHistory.get(r.pid) ?? []) : [];
123
+ if (hist.length && hist.at(-1)[2] !== r.footprintSource) hist = [];
124
+ hist.push([now, r.footprintBytes, r.footprintSource]);
115
125
  while (hist.length && now - hist[0][0] > 130_000) hist.shift();
116
126
  this.footHistory.set(r.pid, hist);
117
127
  const ago = hist.findLast?.(([at]) => now - at >= 55_000) ?? [...hist].reverse().find(([at]) => now - at >= 55_000);
@@ -225,6 +235,10 @@ export class Sampler {
225
235
  if (!['*', '0.0.0.0', '::', '[::]'].includes(s.local.addr)) local.add(s.local.addr);
226
236
  }
227
237
  const inRange = (port) => port >= range.first && port <= range.last;
238
+ for (const port of listening) this.recentListeners.set(port, now);
239
+ const hold = Math.max(4 * timeWaitSec * 1000, 120_000);
240
+ for (const [port, at] of this.recentListeners) if (now - at > hold) this.recentListeners.delete(port);
241
+ const wasListening = (port) => this.recentListeners.has(port);
228
242
  const wildcard = new Set();
229
243
  const used = new Map();
230
244
  const add = (addr, port) => { if (!used.has(addr)) used.set(addr, new Set()); used.get(addr).add(port); };
@@ -236,9 +250,11 @@ export class Sampler {
236
250
  if (local.has(s.foreign.addr) && inRange(s.foreign.port)) add(s.foreign.addr, s.foreign.port);
237
251
  if (s.state !== 'TIME_WAIT') continue;
238
252
  // Group each TIME_WAIT by the endpoint it churned against: a listener here (either side), else the remote.
253
+ // A listener that exited still owns its TIME_WAITs until they drain, so a port counts as a listener for
254
+ // a while after it was last seen listening; otherwise its alarm would clear while the sockets remain.
239
255
  let target;
240
- if (listening.has(s.local.port)) target = `:${s.local.port}`;
241
- else if ((local.has(s.foreign.addr) || LOOPBACK.test(s.foreign.addr)) && listening.has(s.foreign.port)) target = `:${s.foreign.port}`;
256
+ if (wasListening(s.local.port)) target = `:${s.local.port}`;
257
+ else if ((local.has(s.foreign.addr) || LOOPBACK.test(s.foreign.addr)) && wasListening(s.foreign.port)) target = `:${s.foreign.port}`;
242
258
  else target = `${s.foreign.addr}:${s.foreign.port}`;
243
259
  targets.set(target, (targets.get(target) ?? 0) + 1);
244
260
  }
@@ -255,12 +271,13 @@ export class Sampler {
255
271
  } else owners = this.owners ?? new Map();
256
272
  const timeWaitByTarget = top.map(([target, count]) => {
257
273
  const owner = target.startsWith(':') ? owners.get(Number(target.slice(1))) : null;
258
- return { target, count, pct: round((count / size) * 100), ...(owner ? { listener: { pid: owner.pid, command: short(owner.command) } } : {}) };
274
+ const exited = target.startsWith(':') && !listening.has(Number(target.slice(1)));
275
+ return { target, count, pct: round((count / size) * 100), ...(exited ? { exited: true } : {}), ...(owner ? { listener: { pid: owner.pid, command: short(owner.command) } } : {}) };
259
276
  });
260
277
  readings.tcp = { states, timeWait: states.TIME_WAIT ?? 0, range: { ...range, size }, timeWaitSec, ephemeral, timeWaitByTarget };
261
278
  families.add('tcp');
262
279
  for (const e of ephemeral) observations.push({ key: `tcp.ephemeral:${e.addr}`, family: 'tcp', def: alarm('tcp.ephemeral'), value: e.pct, unit: '% of ports', summary: `${e.used} of ${size} ephemeral ports in use on ${e.addr}` });
263
- for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
280
+ for (const t of timeWaitByTarget) observations.push({ key: `tcp.timeWaitTarget:${t.target}`, family: 'tcp', def, value: t.pct, unit: '% of ports', summary: `${t.count} TIME_WAIT against ${t.target.startsWith(':') ? `listener ${t.target}${t.exited ? ' (listener has exited; its TIME_WAITs are draining)' : ''}` : t.target}${t.listener ? ` (pid ${t.listener.pid})` : ''}; range ${size}, TIME_WAIT lasts ${timeWaitSec} s`, detail: t });
264
281
  });
265
282
 
266
283
  // ---- the slow set ---------------------------------------------------------------------------------
@@ -327,12 +344,18 @@ export class Sampler {
327
344
  const log = s.log?.replace(/^~(?=\/)/, homedir());
328
345
  if (log) try { logAgeMin = Math.max(0, round((now - statSync(log).mtimeMs) / 60_000)); } catch { logAgeMin = null; }
329
346
  const running = Boolean(job?.pid);
330
- const down = !job || (!running && (job.lastExit !== 0 || s.keepAlive !== false));
331
- const why = !job ? 'not loaded' : !running ? `not running (last exit ${job.lastExit})` : null;
347
+ // Down (ADR 0006): not loaded, not running, or failing. A KeepAlive job that crashes and is respawned
348
+ // always has a pid when read, so failing is runs rising since the last reading with a non-zero last exit.
349
+ const before = this.serviceRuns.get(s.label);
350
+ const restarts = job && before != null && job.runs >= before ? job.runs - before : 0;
351
+ if (job) this.serviceRuns.set(s.label, job.runs);
352
+ const crashing = Boolean(job && restarts > 0 && job.lastExit !== 0);
353
+ const down = !job || (!running && (job.lastExit !== 0 || s.keepAlive !== false)) || crashing;
354
+ const why = !job ? 'not loaded' : !running ? `not running (last exit ${job.lastExit})` : crashing ? `crash-looping: ${restarts} restart${restarts === 1 ? '' : 's'} since the last reading, last exit ${job.lastExit} (${job.runs} runs since loaded)` : null;
332
355
  if (down) slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 1, met: { warn: true, crit: false, clears: false }, summary: `service ${s.name ?? s.label} is ${why}` });
333
356
  else slow.observations.push({ key: `service.down:${s.label}`, family: 'service', def: alarm('service.down'), value: 0, met: { warn: false, crit: false, clears: true }, summary: `service ${s.name ?? s.label} running` });
334
357
  if (s.log && s.maxSilentMin) slow.observations.push({ key: `service.deaf:${s.label}`, family: 'service', def: { warn: s.maxSilentMin, forSec: 0 }, value: logAgeMin ?? Infinity, unit: 'min silent', summary: logAgeMin == null ? `service ${s.name ?? s.label}: its log ${log} is missing` : `service ${s.name ?? s.label} has written nothing to its log for ${logAgeMin} min` });
335
- return { label: s.label, name: s.name ?? null, running, pid: job?.pid ?? null, lastExit: job?.lastExit ?? null, ...(s.log ? { logAgeMin } : {}) };
358
+ return { label: s.label, name: s.name ?? null, running, pid: job?.pid ?? null, lastExit: job?.lastExit ?? null, runs: job?.runs ?? null, restarts, crashing, ...(s.log ? { logAgeMin } : {}) };
336
359
  });
337
360
  });
338
361
 
@@ -435,27 +458,44 @@ export class Sampler {
435
458
  for (let i = 0; i < 40 && p > 1; i += 1) { const r = byPid.get(p); if (!r) break; out.push(r); p = r.ppid; }
436
459
  return out;
437
460
  };
461
+ // Who owns a process is the agent above it, named by the agent itself: the session id on its own command
462
+ // line (`--resume`/`--session-id`, `codex resume`), else the id it sets in its own children's environment.
463
+ // An environment is never trusted across a tmux server: every pane inherits the variables of whichever
464
+ // session started that server (measured 2026-10-02: one tmux server credited one Claude session with
465
+ // dozens of unrelated panes). Above no agent, an inherited id is reported as inherited, never as owner.
438
466
  const need = new Set();
439
467
  const plan = new Map();
468
+ const childOf = (pid) => this.procs.rows.find((x) => x.ppid === pid && !AGENT.test(base(x.command)));
440
469
  for (const pid of wanted) {
441
470
  const r = byPid.get(pid);
442
471
  if (!r) continue;
443
472
  const cacheKey = `${pid}:${r.command}`;
444
473
  if (this.sessionCache.has(cacheKey)) { plan.set(pid, { cacheKey }); continue; }
445
474
  const up = chain(pid);
446
- const agentIndex = up.findIndex((x) => AGENT.test(base(x.command)));
447
- const lineage = agentIndex >= 0 ? up.slice(0, agentIndex + 1) : up.slice(0, 3);
448
- for (const x of lineage) need.add(x.pid);
449
- plan.set(pid, { cacheKey, lineage, agent: agentIndex >= 0 ? up[agentIndex] : null });
475
+ const stop = up.findIndex((x) => AGENT.test(base(x.command)) || base(x.command) === 'tmux');
476
+ const top = stop >= 0 ? up[stop] : null;
477
+ const agent = top && AGENT.test(base(top.command)) ? top : null;
478
+ // The agent's direct child on this lineage carries the variables the agent set; for the agent itself, any child.
479
+ const witness = agent ? (stop > 0 ? up[stop - 1] : childOf(agent.pid)) : (top ? null : r);
480
+ if (witness) need.add(witness.pid);
481
+ plan.set(pid, { cacheKey, agent, witness, pane: Boolean(top && !agent) });
450
482
  }
451
483
  const env = need.size ? await this.#environments([...need]) : new Map();
452
484
  for (const [pid, p] of plan) {
453
485
  if (!this.sessionCache.has(p.cacheKey)) {
454
- let found = null;
455
- for (const x of p.lineage) { found = env.get(x.pid); if (found) break; }
456
- const harness = p.agent ? (HARNESS[base(p.agent.command)] ?? base(p.agent.command)) : found?.harness ?? null;
457
- const session = found ? { harness: found.harness ?? harness, id: found.id, address: found.address ?? `sc:${this.machine}:${found.harness ?? harness}:${found.id}`, agentPid: p.agent?.pid ?? null }
458
- : p.agent ? { harness, id: null, address: null, agentPid: p.agent.pid } : null;
486
+ let session = null;
487
+ if (p.agent) {
488
+ const harness = HARNESS[base(p.agent.command)] ?? base(p.agent.command);
489
+ const seen = p.witness ? env.get(p.witness.pid) : null;
490
+ const id = argvSession(p.agent.command) ?? (harness === 'codex' ? seen?.codex : harness === 'claude-code' ? seen?.claude : null) ?? null;
491
+ session = { harness, id, address: id ? `sc:${this.machine}:${harness}:${id}` : null, agentPid: p.agent.pid };
492
+ } else if (p.pane) {
493
+ session = { harness: null, id: null, address: null, agentPid: null, note: 'in a tmux pane with no agent above it' };
494
+ } else {
495
+ const seen = p.witness ? env.get(p.witness.pid) : null;
496
+ const inherited = seen?.supercode ?? (seen?.claude ? `sc:${this.machine}:claude-code:${seen.claude}` : seen?.codex ? `sc:${this.machine}:codex:${seen.codex}` : null);
497
+ if (inherited) session = { harness: null, id: null, address: null, agentPid: null, inherited };
498
+ }
459
499
  this.sessionCache.set(p.cacheKey, session);
460
500
  if (this.sessionCache.size > 5000) this.sessionCache.delete(this.sessionCache.keys().next().value);
461
501
  }
@@ -471,10 +511,7 @@ export class Sampler {
471
511
  const supercode = /(?:^|\s|\0)SUPERCODE_CALLER=(sc:[^\s\0]+)/.exec(text)?.[1];
472
512
  const claude = /(?:^|\s|\0)CLAUDE_CODE_SESSION_ID=([0-9a-f-]{36})/.exec(text)?.[1];
473
513
  const codex = /(?:^|\s|\0)CODEX_THREAD_ID=([0-9a-zA-Z-]{8,})/.exec(text)?.[1];
474
- if (claude) return { harness: 'claude-code', id: claude };
475
- if (codex) return { harness: 'codex', id: codex };
476
- if (supercode) { const [, , harness, id] = supercode.split(':'); return { harness, id, address: supercode }; }
477
- return null;
514
+ return supercode || claude || codex ? { supercode, claude, codex } : null;
478
515
  };
479
516
  if (this.platform === 'linux') {
480
517
  for (const pid of pids) { try { const found = pick(readFileSync(`/proc/${pid}/environ`, 'utf8')); if (found) map.set(pid, found); } catch { /* another user's */ } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-health",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",