@volter/supercode-health 0.1.14 → 0.1.16
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 +12 -1
- package/lib/features.mjs +1 -0
- package/lib/markers.mjs +59 -0
- package/lib/probe.mjs +37 -3
- package/lib/sample.mjs +12 -0
- package/lib/thresholds.mjs +5 -0
- package/package.json +3 -2
package/lib/drift.mjs
CHANGED
|
@@ -71,7 +71,18 @@ export async function staleSupercode({ rows = null } = {}) {
|
|
|
71
71
|
} else if (Number.isNaN(started) || installed <= started + 1000) continue; // ps says a start to the second
|
|
72
72
|
stale.push({ pid: Number(pid), command: command.slice(0, 200), file, startedAt: new Date(started).toISOString(), installedAt: new Date(installed).toISOString() });
|
|
73
73
|
}
|
|
74
|
-
return stale.sort((a, b) => b.installedAt.localeCompare(a.installedAt));
|
|
74
|
+
return moved(stale).sort((a, b) => b.installedAt.localeCompare(a.installedAt));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Less the processes that answer through the release on disk: an MCP server's pump says so in `<home>/run/mcp/<pid>`
|
|
78
|
+
* once a child of the release an install put there answers for it (lib/mcp.mjs), at or after that install. */
|
|
79
|
+
function moved(stale) {
|
|
80
|
+
const home = process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(homedir(), '.config', 'supercode'));
|
|
81
|
+
return stale.filter((row) => {
|
|
82
|
+
let said = null;
|
|
83
|
+
try { said = JSON.parse(readFileSync(join(home, 'run', 'mcp', String(row.pid)), 'utf8')); } catch { return true; }
|
|
84
|
+
return !(said?.pid === row.pid && Date.parse(said.at) >= Date.parse(row.installedAt));
|
|
85
|
+
});
|
|
75
86
|
}
|
|
76
87
|
|
|
77
88
|
/**
|
package/lib/features.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export const FEATURES = [
|
|
|
14
14
|
{ id: 'tcp', barrier: 'none', platforms: ['darwin', 'linux', 'win32'], reads: 'sockets per state, ephemeral ports in use, TIME_WAIT by listener and its pid' },
|
|
15
15
|
{ id: 'files', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'open files against the system limit' },
|
|
16
16
|
{ id: 'recorded', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'every command supercode recorded outside itself (harness configs, hooks baked into live sessions), one of each program and verb run as its consumer runs it' },
|
|
17
|
+
{ id: 'markers', barrier: 'none', platforms: ['darwin', 'linux'], reads: "terminal windows and tmux processes carrying a harness's child-session mark; Claude Code on a terminal with no file in its session registry (one ps)" },
|
|
17
18
|
{ id: 'drift', barrier: 'none', platforms: ['darwin', 'linux'], reads: 'running supercode processes whose install was replaced after they started (one ps)' },
|
|
18
19
|
{ id: 'services', barrier: 'none', platforms: ['darwin', 'linux'], reads: "configured launchd/systemd user jobs: running, last exit, log silence" },
|
|
19
20
|
{ id: 'supercode', barrier: 'integration:machine-daemon', platforms: ['darwin', 'linux', 'win32'], reads: "this machine's supercode connector: answering, server link, stuck launches" },
|
package/lib/markers.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Harness marks where no session is (R75): a coding harness marks what its tools run (Claude Code's CLAUDECODE and
|
|
2
|
+
// CLAUDE_CODE_CHILD_SESSION), and a terminal window or tmux process that carries the marks hands them to every shell a
|
|
3
|
+
// person opens there. Claude Code started in such a shell takes itself for a nested session: it saves no transcript,
|
|
4
|
+
// writes no file in its session registry, and supercode's mail cannot tell who it is. Twelve windows on
|
|
5
|
+
// yuerans-macbook-pro carried the marks from 2026-10-07 to 10-09 and nothing read them. Two readings, one ps:
|
|
6
|
+
// - every terminal app and tmux process whose own environment holds a child-session mark with a value;
|
|
7
|
+
// - every Claude Code process on a terminal with no file in its session registry (`<claude home>/sessions/<pid>.json`).
|
|
8
|
+
// Only names and pids are kept: an environment's values never leave this function.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { run, clockSeconds } from './run.mjs';
|
|
14
|
+
|
|
15
|
+
/** The marks a harness takes itself for a nested session by: the terminal SDK's list, the one home of it. */
|
|
16
|
+
export function childSessionMarkers() {
|
|
17
|
+
const file = createRequire(import.meta.url).resolve('@volter/supercode-terminal/harness-markers.json');
|
|
18
|
+
return JSON.parse(readFileSync(file, 'utf8')).child_session;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TERMINALS = new Set(['wezterm-gui', 'wezterm', 'kitty', 'ghostty', 'alacritty', 'iTerm2', 'Terminal', 'xterm', 'tmux']);
|
|
22
|
+
const CLAUDE = new Set(['claude', 'claude.exe']);
|
|
23
|
+
const program = (command) => (command.split(/\s+/u)[0] ?? '').split('/').pop();
|
|
24
|
+
|
|
25
|
+
/** One process's environment as [name, value] pairs: Linux's own file for it; on macOS `ps -E` printed it after the command. */
|
|
26
|
+
function environmentOf(pid, printed) {
|
|
27
|
+
if (process.platform === 'linux') {
|
|
28
|
+
try { return readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0').map((entry) => entry.split(/=(.*)/su)); } catch { return []; }
|
|
29
|
+
}
|
|
30
|
+
return [...printed.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=(\S*)/gu)].map((match) => [match[1], match[2]]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* { marked: [{ pid, program, markers }], unregistered: [{ pid, tty, ageSec, registry }] }. `minAgeSec` leaves a Claude
|
|
35
|
+
* Code process time to write its registry file.
|
|
36
|
+
*/
|
|
37
|
+
export async function harnessMarks({ markers = childSessionMarkers(), minAgeSec = 120, ps = null } = {}) {
|
|
38
|
+
const listed = ps ?? await run('/bin/ps', [process.platform === 'linux' ? '-axww' : '-axEww', '-o', 'pid=,tty=,etime=,command='], { timeoutMs: 10_000 });
|
|
39
|
+
if (!listed.ok) throw new Error(`ps: ${listed.error}`);
|
|
40
|
+
const marked = [], unregistered = [];
|
|
41
|
+
for (const line of listed.stdout.split('\n')) {
|
|
42
|
+
const row = /^\s*(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/u.exec(line);
|
|
43
|
+
if (!row) continue;
|
|
44
|
+
const [, pid, tty, etime, command] = row, name = program(command);
|
|
45
|
+
if (!TERMINALS.has(name) && !CLAUDE.has(name)) continue;
|
|
46
|
+
const environment = new Map(environmentOf(pid, command));
|
|
47
|
+
if (TERMINALS.has(name)) {
|
|
48
|
+
const carried = markers.filter((marker) => environment.get(marker));
|
|
49
|
+
if (carried.length) marked.push({ pid: Number(pid), program: name, markers: carried });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// a Claude Code process with no terminal is a tool's own child (a subagent, a print run): nobody resumes or mails it
|
|
53
|
+
if (/^\?+$|^-$/u.test(tty) || clockSeconds(etime) < minAgeSec) continue;
|
|
54
|
+
const home = environment.get('CLAUDE_CONFIG_DIR') || join(environment.get('HOME') || homedir(), '.claude');
|
|
55
|
+
const registry = join(home, 'sessions', `${pid}.json`);
|
|
56
|
+
if (!existsSync(registry)) unregistered.push({ pid: Number(pid), tty, ageSec: clockSeconds(etime), registry });
|
|
57
|
+
}
|
|
58
|
+
return { marked, unregistered };
|
|
59
|
+
}
|
package/lib/probe.mjs
CHANGED
|
@@ -51,6 +51,9 @@ export class Probe {
|
|
|
51
51
|
this.f = healthFiles(dir);
|
|
52
52
|
this.machine = machineName(machine || env.SUPERCODE_HEALTH_MACHINE || hostname());
|
|
53
53
|
this.state = {};
|
|
54
|
+
// what the last mail said of each alarm (key → its level, summary, detail): a mail says what changed since, never each
|
|
55
|
+
// flip in between (t_be880329: disk and memory flapping mailed the maintainer ~1,200 times a day on yuerans)
|
|
56
|
+
this.said = new Map(); this.changesMailAt = 0; this.raisedLastAt = new Map(); // key → when it was last seen raised
|
|
54
57
|
this.unknowns = {}; this.unknownMailAt = {}; // unknownMailAt: a kind of unknown (unknownKind: its key's prefix) → last mailed
|
|
55
58
|
this.lastHistory = 0;
|
|
56
59
|
this.sampleMs = null;
|
|
@@ -99,6 +102,7 @@ export class Probe {
|
|
|
99
102
|
// A raised alarm whose reading was unknown keeps its unknown since across the restart, as its unknowns entry does.
|
|
100
103
|
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,
|
|
101
104
|
...(a.unknown?.since ? { unread: a.unknown.why, unreadAt: Date.parse(a.unknown.since) } : {}) };
|
|
105
|
+
for (const a of (previous?.alarms ?? [])) this.said.set(a.key, { level: a.level, summary: a.summary, detail: a.detail ?? null });
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
/** Remove an earlier pack's outbox; answers how many mails it held, logged by recipient. */
|
|
@@ -138,7 +142,26 @@ export class Probe {
|
|
|
138
142
|
const repeatMs = this.config.repeatMin * 60_000;
|
|
139
143
|
// A raised alarm is reminded each repeatMin after it was last said: in a mail of its own transition or reminder, never
|
|
140
144
|
// pushed back by a mail about something else.
|
|
141
|
-
|
|
145
|
+
// What changed since the last mail said it (net of every raise and clear between): said at most once each quietMin,
|
|
146
|
+
// a new crit at once, and with any mail going anyway (a reminder, an unknown due). A key that raised and cleared
|
|
147
|
+
// between two mails is never said.
|
|
148
|
+
const quietMs = (this.config.quietMin ?? 15) * 60_000;
|
|
149
|
+
const levels = new Map(alarms.map((a) => [a.key, a]));
|
|
150
|
+
const lastTransition = new Map(transitions.map((t) => [t.key, t]));
|
|
151
|
+
const net = [];
|
|
152
|
+
for (const a of alarms) {
|
|
153
|
+
const was = this.said.get(a.key)?.level ?? 'none';
|
|
154
|
+
if (was !== a.level) net.push({ key: a.key, from: was, to: a.level, value: a.value, unit: a.unit ?? null, summary: a.summary, detail: a.detail ?? null });
|
|
155
|
+
}
|
|
156
|
+
for (const a of alarms) this.raisedLastAt.set(a.key, now);
|
|
157
|
+
for (const [key, was] of this.said) {
|
|
158
|
+
if (levels.has(key)) continue;
|
|
159
|
+
// a clear is said once it has held a whole quiet window: an alarm that flaps is said raised once, never each clear
|
|
160
|
+
if (now - (this.raisedLastAt.get(key) ?? 0) < quietMs) continue;
|
|
161
|
+
const t = lastTransition.get(key);
|
|
162
|
+
net.push({ key, from: was.level, to: 'none', value: null, summary: t?.to === 'none' ? t.summary : `${was.summary} — cleared`, detail: t?.detail ?? was.detail });
|
|
163
|
+
}
|
|
164
|
+
const raisedNow = new Set(net.filter((t) => t.to !== 'none').map((t) => t.key));
|
|
142
165
|
const reminders = alarms.filter((a) => !raisedNow.has(a.key) && now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
|
|
143
166
|
// A reading unknown for unknownMin is the maintainers' to know (raised or not), and again each repeatMin until read.
|
|
144
167
|
// Timed per kind (a key's prefix; a family's whole key): a kind is due once its earliest key has been unknown
|
|
@@ -159,14 +182,25 @@ export class Probe {
|
|
|
159
182
|
const to = subscribers(this.f, this.config);
|
|
160
183
|
// after dropping an earlier pack's undelivered mail, the first mail states the whole current state, clears included
|
|
161
184
|
const composedState = Boolean(this.droppedOutbox && to.length);
|
|
162
|
-
const
|
|
185
|
+
const changesDue = net.length > 0 && (now - this.changesMailAt >= quietMs || net.some((t) => t.to === 'crit' && t.from !== 'crit') || reminders.length > 0 || unknownsDue.length > 0);
|
|
186
|
+
const changes = changesDue ? net : [];
|
|
187
|
+
const mail = composedState ? composeState({ machine: this.machine, raised: alarms }) : compose({ machine: this.machine, transitions: changes, reminders: reminders.filter((a) => !changes.some((t) => t.key === a.key)), raised: alarms, unknowns: unknownsDue });
|
|
163
188
|
if (composedState) this.droppedOutbox = 0;
|
|
164
189
|
if (mail) {
|
|
165
190
|
// the kinds of unknown this mail said (the whole-state mail says none: they stay due)
|
|
166
191
|
if (!composedState) for (const [kind] of dueKinds) this.unknownMailAt[kind] = now;
|
|
167
192
|
// only the alarms this mail said: raised by it, or reminded (the whole-state mail says every one)
|
|
168
|
-
const said = composedState ? alarms : alarms.filter((a) => raisedNow.has(a.key) || reminders.includes(a));
|
|
193
|
+
const said = composedState ? alarms : alarms.filter((a) => (changes.length && raisedNow.has(a.key)) || reminders.includes(a));
|
|
169
194
|
for (const a of said) this.state[a.key].lastMailAt = now;
|
|
195
|
+
// the state this mail said is what the next mail is measured from: the whole state, or the last one said with this
|
|
196
|
+
// mail's changes applied. A clear still in its hold was not said, so its alarm stays said raised until a mail
|
|
197
|
+
// says the clear (#1268 review P1: replacing it with the alarms raised now lost that clear for good)
|
|
198
|
+
if (composedState) this.said = new Map(alarms.map((a) => [a.key, { level: a.level, summary: a.summary, detail: a.detail ?? null }]));
|
|
199
|
+
else for (const t of changes) {
|
|
200
|
+
if (t.to === 'none') this.said.delete(t.key);
|
|
201
|
+
else this.said.set(t.key, { level: t.to, summary: t.summary, detail: t.detail ?? null });
|
|
202
|
+
}
|
|
203
|
+
if (composedState || changes.length) this.changesMailAt = now;
|
|
170
204
|
this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
|
|
171
205
|
}
|
|
172
206
|
const snapshot = {
|
package/lib/sample.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { expiredFailures, failureSummary, keyFor, Ledger, runningRelease, sayOnC
|
|
|
9
9
|
import { healthDir } from './paths.mjs';
|
|
10
10
|
import { pinnedHomes, staleSupercode } from './drift.mjs';
|
|
11
11
|
import { brokenRecorded } from './recorded.mjs';
|
|
12
|
+
import { harnessMarks } from './markers.mjs';
|
|
12
13
|
import { join } from 'node:path';
|
|
13
14
|
|
|
14
15
|
const MB = 1024 * 1024;
|
|
@@ -408,6 +409,17 @@ export class Sampler {
|
|
|
408
409
|
slow.readings.recorded = broken;
|
|
409
410
|
for (const b of broken) slow.observations.push({ key: `recorded.broken:${b.shape}`, family: 'recorded', def: alarm('recorded.broken'), value: b.count, met: { warn: true, crit: true, clears: false }, summary: `${b.count} recorded command(s) cannot run (${b.where.slice(0, 3).join(', ')}): ${b.error}; ${b.command.slice(0, 120)}`, detail: b });
|
|
410
411
|
});
|
|
412
|
+
if (on.has('markers')) await attempt('markers', async () => {
|
|
413
|
+
const def = alarm('harness.unregistered');
|
|
414
|
+
const { marked, unregistered } = await harnessMarks({ minAgeSec: def.minAgeSec });
|
|
415
|
+
slow.families.add('marked'); slow.families.add('unregistered');
|
|
416
|
+
// every marked window and unregistered session this pass found; one no longer found is cleared
|
|
417
|
+
slow.observations.push({ enumerated: 'marked', family: 'marked', by: 'suffix', entities: new Set(marked.map((m) => String(m.pid))) });
|
|
418
|
+
slow.observations.push({ enumerated: 'unregistered', family: 'unregistered', by: 'suffix', entities: new Set(unregistered.map((u) => String(u.pid))) });
|
|
419
|
+
slow.readings.markers = { marked, unregistered };
|
|
420
|
+
for (const m of marked) slow.observations.push({ key: `harness.marked:${m.pid}`, family: 'marked', def: alarm('harness.marked'), value: 1, met: { warn: true, crit: true, clears: false }, summary: `${m.program} ${m.pid} carries a harness's child-session mark (${m.markers.join(', ')}): Claude Code started in any shell it opens saves no transcript and gets no mail; replace that window`, detail: m });
|
|
421
|
+
for (const u of unregistered) slow.observations.push({ key: `harness.unregistered:${u.pid}`, family: 'unregistered', def, value: 1, met: { warn: true, crit: true, clears: false }, summary: `Claude Code ${u.pid} on ${u.tty} has no ${u.registry}: it saves nothing and no mail reaches it`, detail: u });
|
|
422
|
+
});
|
|
411
423
|
if (on.has('services')) await attempt('services', async () => {
|
|
412
424
|
const list = cfg.services ?? [];
|
|
413
425
|
const jobs = await this.reader.services(list.map((s) => s.label));
|
package/lib/thresholds.mjs
CHANGED
|
@@ -5,6 +5,7 @@ export const DEFAULTS = {
|
|
|
5
5
|
slowIntervalSec: 60, // disk space, services, the connector, tabs
|
|
6
6
|
footprintTopEverySec: 300, // macOS: every user's largest footprints through top (about 2 s of top per run)
|
|
7
7
|
repeatMin: 60, // a raised alarm mails again this often (netdata's repeat cap)
|
|
8
|
+
quietMin: 15, // what changed is mailed at most this often (a new crit at once): a flapping alarm is no stream
|
|
8
9
|
unknownMin: 15, // a reading unknown this long (raised or not) is mailed to the maintainers, and again each repeatMin
|
|
9
10
|
historyHours: 24,
|
|
10
11
|
daemons: {
|
|
@@ -38,6 +39,10 @@ export const DEFAULTS = {
|
|
|
38
39
|
'release.pinned': { forSec: 300 },
|
|
39
40
|
// a recorded command that cannot run fails its harness on every use (a hook on every tool call): said at once
|
|
40
41
|
'recorded.broken': { forSec: 0 },
|
|
42
|
+
// a window whose shells start Claude Code as a nested session, and a session that saves nothing: said at once (a
|
|
43
|
+
// Claude Code process is read only once it has run minAgeSec, the time it has to write its registry file)
|
|
44
|
+
'harness.marked': { forSec: 0 },
|
|
45
|
+
'harness.unregistered': { forSec: 0, minAgeSec: 120 },
|
|
41
46
|
'supercode.connector': { forSec: 60 },
|
|
42
47
|
'supercode.launch': { stuckMin: 5, failedWithinMin: 30 },
|
|
43
48
|
// one failure that keeps happening (the same command or door with the same outcome, the same mail expiry) in the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
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",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"./format": "./lib/format.mjs",
|
|
11
11
|
"./paths": "./lib/paths.mjs",
|
|
12
12
|
"./drift": "./lib/drift.mjs",
|
|
13
|
-
"./recorded": "./lib/recorded.mjs"
|
|
13
|
+
"./recorded": "./lib/recorded.mjs",
|
|
14
|
+
"./markers": "./lib/markers.mjs"
|
|
14
15
|
},
|
|
15
16
|
"bin": {
|
|
16
17
|
"supercode-health": "./bin/health.mjs"
|