@bli-cockpit/cli 0.2.283 → 0.2.285

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.
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileExists, MEMORY_GUARD_AUTOSTART_LABEL, memoryGuardPlistPathFor, WINDOWS_MEMORY_GUARD_TASK_NAME, } from "./autostart-contract.js";
5
5
  import { describeDifference, renderedFileDifference, renderWindowsLauncherScript, UTF8_BOM, windowsPowerShellPath, windowsWScriptPath, } from "./autostart-windows-scripts.js";
6
6
  import { xmlEscape, decodeXmlEntities } from "./autostart-xml.js";
7
- import { lastKillIn, MEMORY_GUARD_DISABLED_MARKER, MEMORY_GUARD_LOG, memoryGuardLimitsFor, memoryGuardStateFile, memoryGuardTurnedOff, memoryGuardTurnedOn, } from "./memory-guard-contract.js";
7
+ import { lastKillIn, MEMORY_GUARD_DISABLED_MARKER, MEMORY_GUARD_LOG, memoryGuardLimitsFor, swapLimitFor, memoryGuardStateFile, memoryGuardTurnedOff, memoryGuardTurnedOn, } from "./memory-guard-contract.js";
8
8
  import { renderDarwinMemoryGuardScript } from "./memory-guard-script-darwin.js";
9
9
  import { renderWindowsMemoryGuardRegistration, renderWindowsMemoryGuardScript } from "./memory-guard-script-windows.js";
10
10
  /**
@@ -70,7 +70,7 @@ function darwinFiles(options) {
70
70
  return {
71
71
  scriptPath,
72
72
  plistPath: memoryGuardPlistPathFor(options.homeDir ?? os.homedir()),
73
- script: renderDarwinMemoryGuardScript({ limits: limitsOf(options), logPath: stateFile(options, MEMORY_GUARD_LOG) }),
73
+ script: renderDarwinMemoryGuardScript({ limits: limitsOf(options), logPath: stateFile(options, MEMORY_GUARD_LOG), swapLimitMb: swapLimitFor(options) }),
74
74
  plist: renderMemoryGuardPlist({ scriptPath, stdoutPath: stateFile(options, GUARD_OUT_LOG), stderrPath: stateFile(options, GUARD_ERR_LOG) }),
75
75
  };
76
76
  }
@@ -20,7 +20,16 @@ const HOW_WORDS = { tap: "approved by you", edit: "approved by you with an edit"
20
20
  function checkWhat(actKind) {
21
21
  return `check ${checkWhere(actKind)}, then approve with --checked-sent sent or --checked-sent not-sent`;
22
22
  }
23
+ /**
24
+ * A meeting review card (`notes.review`) that Tower closed because the review moved on reads `rejected` in the database
25
+ * (the card states have no "withdrawn"), exactly like one the person dismissed. Neither is a "no" to anything, so the
26
+ * words say it was closed and that the review itself is untouched.
27
+ */
28
+ const REVIEW_CARD_KIND = "notes.review";
29
+ const REVIEW_CARD_CLOSED = "closed: the review moved on, or you dismissed this card. The review itself is unchanged";
23
30
  export function statusWords(card) {
31
+ if (card.act_kind === REVIEW_CARD_KIND && card.status === "rejected")
32
+ return REVIEW_CARD_CLOSED;
24
33
  if (card.status === "executing" && card.stuck)
25
34
  return `stuck sending, it may or may not have gone out: ${checkWhat(card.act_kind)}`;
26
35
  if (card.status === "failed" && card.delivery_unknown)
@@ -91,8 +100,10 @@ export function cardDetailLines(card, color) {
91
100
  const lines = [`${card.title} [${statusWords(card)}]`, `Kind: ${card.kind_label}`, `To: ${card.recipient_label}`, `Why: ${card.reason}`, `From: ${sourceLine(card)}`];
92
101
  for (const fact of card.facts ?? [])
93
102
  lines.push(`${fact.label}: ${fact.value}`);
94
- if (card.decided_how)
95
- lines.push(`Decision: ${HOW_WORDS[card.decided_how] ?? card.decided_how}`);
103
+ if (card.decided_how) {
104
+ const closedReview = card.act_kind === REVIEW_CARD_KIND && card.decided_how === "reject";
105
+ lines.push(`Decision: ${closedReview ? REVIEW_CARD_CLOSED : (HOW_WORDS[card.decided_how] ?? card.decided_how)}`);
106
+ }
96
107
  if (card.rule)
97
108
  lines.push(`Standing rule: ${card.rule.label}`);
98
109
  lines.push("", ...contentLines(card));
@@ -29,7 +29,7 @@ export const TOWER_OPS_COMMAND_HELP = [
29
29
  " - lines on stdin: plain text, `journalctl -o json`, `railway logs --json` or `vercel logs --json`.",
30
30
  " <project> a BLI project (tower, kolm, portpal, pearl-portal, triage-crm, ...; `--list` names them): the digest Tower's",
31
31
  " own 15-minute cron STORED from that project's Vercel, Railway and Supabase logs, merged over --since",
32
- " (default 24h, at most 30d), real bugs first. Needs only this machine's Tower login: no vercel or railway",
32
+ " (default 24h, at most 1095d), real bugs first. Needs only this machine's Tower login: no vercel or railway",
33
33
  " login. It keeps no raw line, so --raw, --table and --all-levels do not apply.",
34
34
  "--stored dashboard or railway read Tower's stored digest of project `tower` (this also happens by itself, and says so,",
35
35
  " when the local vercel or railway CLI is missing or logged out).",
@@ -42,7 +42,7 @@ export const NOTES_HELP = [
42
42
  "bot-upcoming [--json] — your own Google Meet meetings today that the bot will join, with claim state. Decline or mark private to opt out.",
43
43
  "granola-status [--limit <n>] [--json] — the Granola cron's own last several runs (ticket 10718): reason, counts, model, spend and which lookback it used. Granola runs on a shared server-side key, not a connected account, so this is its own read rather than `accounts`.",
44
44
  "transcription-stats [--month YYYY-MM] [--json] — what meeting transcription cost and did (BLI-18003): minutes, dollars (list and real), requests, failures and fallbacks per provider for the UTC month (default: this one), the spend against the monthly cap and how close the 80% message and the hold are, how many meetings are waiting for budget, which provider keys the deploy holds (by name), and how old the numbers are. Switch providers or move the cap with `cockpit settings switches set transcription.provider|transcription.fallback_chain|transcription.monthly_cap_usd <value>`.",
45
- "review [<note-id>] [--submit|--accept-all] [--json] — the meeting reviews waiting for you (BLI-22350). A recorded meeting stays with its attendees until one of them clears one review screen: who's who, lines the raw transcript may have put under the wrong speaker, unclear words, spots where the clean-up may have changed the meaning, sensitive moments. Bare `review` lists yours (waiting, being applied, cleared in the last 7 days). `review <note-id>` shows one in that order with every default answer; --json prints the raw read (raw_hash, items, defaults). `--accept-all` submits every default (names as guessed, lines kept, best-guess readings, the words as said where the meaning may have changed, sensitive moments to attendees only) and prints what it accepted. `--submit` reads { \"raw_hash\": ..., \"answers\": [...] } from stdin: edit the defaults from --json and pipe them in. A refusal says to reload when the review changed (stale_raw_hash, stale_guess) or names what is wrong with the answers. Same door as the web screen (/meeting-notes/<note-id>/review) and the notes_review_* MCP tools.",
45
+ "review [<note-id>] [--submit|--accept-all] [--json] — the meeting reviews waiting for you (BLI-22350). A recorded meeting stays with its attendees until one of them clears one review screen: who's who, lines the raw transcript may have put under the wrong speaker, unclear words, spots where the clean-up may have changed the meaning, sensitive moments. Bare `review` lists yours (waiting, being applied, cleared in the last 7 days). `review <note-id>` shows one in that order with every default answer; --json prints the raw read (raw_hash, items, defaults, budget_stop_line). When money stopped the clean-up (kept as transcribed because the meeting is over the one-meeting limit, or waiting because the month's safety limit was reached or the budget could not be checked), one plain line under the title says so with the meeting's length and estimated cost. `--accept-all` submits every default (names as guessed, lines kept, best-guess readings, the words as said where the meaning may have changed, sensitive moments to attendees only) and prints what it accepted. `--submit` reads { \"raw_hash\": ..., \"answers\": [...] } from stdin: edit the defaults from --json and pipe them in. A refusal says to reload when the review changed (stale_raw_hash, stale_guess) or names what is wrong with the answers. Same door as the web screen (/meeting-notes/<note-id>/review) and the notes_review_* MCP tools.",
46
46
  "A large note is read by a model on the server and can take a couple of minutes; the terminal says so before it waits.",
47
47
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
48
48
  "Sharing and moving need a signed-in session the database can see. If this deployment cannot mint one, they are refused as needs_signed_in_session rather than done with no permission check — and reads say when they came back narrower than the browser's.",
@@ -90,6 +90,9 @@ function stretch(label, text) {
90
90
  export function renderReview(body, dashboardUrl) {
91
91
  const out = [];
92
92
  out.push(`${body.note_title ?? "Meeting"}${body.meeting_date ? `, ${body.meeting_date}` : ""}: ${STATE_WORDS[body.state] ?? body.state}.`);
93
+ // Ticket BLI-23569: the server's own line, printed as it came, so the words stay in one place (the dashboard).
94
+ if (body.budget_stop_line)
95
+ out.push(body.budget_stop_line);
93
96
  const of = (kind) => body.items.filter((item) => item.kind === kind);
94
97
  const speakers = of("speaker");
95
98
  if (speakers.length)
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.283");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.285");
25
25
  return 0;
26
26
  }
27
27
 
@@ -26,10 +26,30 @@ export const MEMORY_GUARD_REASONS = [
26
26
  "rss_over_limit",
27
27
  "pressure_warning",
28
28
  "pressure_critical",
29
+ "swap_pressure",
30
+ "swap_high_not_node",
31
+ "scan_timeout_footprint",
32
+ "scan_timeout_ps",
33
+ "scan_timeout_top",
34
+ "scan_timeout_sysctl",
29
35
  "orphan_vitest_worker",
30
36
  ];
31
- export const MEMORY_GUARD_EVENTS = ["killed", "kill_failed", "orphan_reaped", "started"];
37
+ export const MEMORY_GUARD_EVENTS = ["killed", "kill_failed", "orphan_reaped", "scan_timeout", "swap_high", "started"];
32
38
  const MB = 1024 * 1024;
39
+ /** The swap limit when RAM is unknown (a 16 GB Mac). */
40
+ export const MEMORY_GUARD_DEFAULT_SWAP_LIMIT_MB = 16384;
41
+ /**
42
+ * Swap in use (MB) past which the guard kills the node process holding a quarter of it: as much as the
43
+ * machine has RAM, clamped to 4 GB..32 GB. A 16 GB Mac that lives at 11 GB of swap is under it; the 43 GB of
44
+ * 2026-10-28 is far over.
45
+ */
46
+ export function resolveSwapLimitMb(totalMemoryBytes) {
47
+ const totalMb = Math.floor(totalMemoryBytes / MB);
48
+ return Math.min(32768, Math.max(4096, totalMb));
49
+ }
50
+ export function swapLimitFor(options) {
51
+ return resolveSwapLimitMb(options.totalMemoryBytes ?? os.totalmem());
52
+ }
33
53
  /**
34
54
  * hard = min(6144 MB, 40% of RAM); warning tier = min(4096, 25%); critical tier = min(2048, 12.5%).
35
55
  * `COCKPIT_MEMORY_GUARD_LIMIT_MB` replaces the hard limit and can only pull the other tiers down with it.
@@ -109,12 +129,14 @@ export function parseMemoryGuardLogLine(raw) {
109
129
  cmd_sha256: str("cmd_sha256"),
110
130
  };
111
131
  }
112
- /** The last kill (any event but "started") in a log's text, newest wins. */
132
+ /** Events that mean a process was killed (or the kill failed). A scan timeout or a swap warning is not a kill. */
133
+ export const MEMORY_GUARD_KILL_EVENTS = ["killed", "kill_failed", "orphan_reaped"];
134
+ /** The last kill (not "started", a scan timeout or a swap warning) in a log's text, newest wins. */
113
135
  export function lastKillIn(logText) {
114
136
  let found;
115
137
  for (const raw of logText.split("\n")) {
116
138
  const line = raw.trim() ? parseMemoryGuardLogLine(raw) : null;
117
- if (line && line.event !== "started")
139
+ if (line && MEMORY_GUARD_KILL_EVENTS.includes(line.event))
118
140
  found = { ts: line.ts, kind: line.kind, reason: line.reason, footprint_mb: line.footprint_mb };
119
141
  }
120
142
  return found;
@@ -13,7 +13,7 @@ import { MEMORY_GUARD_CURSOR, MEMORY_GUARD_LOG, MEMORY_GUARD_STEP, memoryGuardSt
13
13
  */
14
14
  const TAG = "[memory-guard-forward]";
15
15
  const MAX_LINES_PER_TICK = 40;
16
- const FORWARDED_EVENTS = new Set(["killed", "kill_failed", "orphan_reaped"]);
16
+ const FORWARDED_EVENTS = new Set(["killed", "kill_failed", "orphan_reaped", "scan_timeout", "swap_high"]);
17
17
  function parseCursor(raw) {
18
18
  if (!raw)
19
19
  return null;
@@ -1,18 +1,27 @@
1
- import { MEMORY_GUARD_KIND_PATTERNS, MEMORY_GUARD_LOG_KEEP_LINES, MEMORY_GUARD_LOG_LIMIT_BYTES, MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS, } from "./memory-guard-contract.js";
1
+ import { MEMORY_GUARD_DEFAULT_SWAP_LIMIT_MB, MEMORY_GUARD_KIND_PATTERNS, MEMORY_GUARD_LOG_KEEP_LINES, MEMORY_GUARD_LOG_LIMIT_BYTES, MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS, } from "./memory-guard-contract.js";
2
2
  import { shellQuote } from "./autostart-darwin-plist.js";
3
3
  /**
4
- * The macOS memory guard (BLI-23571): a bash loop launchd keeps alive.
4
+ * The macOS memory guard (BLI-23571, fixed by BLI-23628): a bash loop launchd keeps alive.
5
5
  *
6
- * Every 2 s it reads the kernel's memory-pressure level and a cheap `ps` RSS check. Every 10 s (every 2 s
7
- * under critical pressure) it scans real memory FOOTPRINT with `top -o mem`, because RSS alone misses
8
- * runaways: swapped and compressed pages leave RSS, and an 800 MB buffer showed 12 MB RSS. The limit
9
- * tightens with pressure. Every 60 s it reaps vitest workers orphaned by a dead runner (ppid 1, older than
10
- * 5 minutes). Only a process whose executable is `node` is ever touched; the guard never kills itself.
6
+ * Every 2 s tick it reads the kernel's memory-pressure level and swap in use, lists the node processes
7
+ * with `ps`, and judges them by memory FOOTPRINT (what Activity Monitor shows: dirty plus compressed plus
8
+ * swapped pages), read with `footprint -p` (about 5 ms per process; `ps` RSS alone misses runaways because
9
+ * swapped and compressed pages leave RSS: 39 MB RSS against 1515 MB footprint, reproduced). Every tick it
10
+ * reads the node processes that are big (RSS 128 MB or more) or were seen big (footprint 512 MB or more);
11
+ * every fifth tick (10 s, the old cadence), and every tick under memory pressure or high swap, it reads all of them. The limit
12
+ * tightens with pressure. When swap in use passes the swap limit and a node process holds a quarter of it,
13
+ * that process is killed even under the footprint limit. Every external tool runs under a deadline
14
+ * (`bounded`, 4 s for scans): a stalled `top`, `ps` or `footprint` used to freeze the whole loop, so
15
+ * nothing after it ran; now it is killed, logged as `scan_timeout`, and the tick moves on. Every 60 s it
16
+ * reaps vitest workers orphaned by a dead runner (ppid 1, older than 5 minutes). Only a process whose
17
+ * executable is `node` is ever touched; the guard never kills itself.
11
18
  *
12
19
  * It logs one JSON line per decision to the state dir: ids, sizes and a sha256 prefix, never a command
13
20
  * line (those carry tokens). Test seams, all env: `MEMGUARD_SOURCE_ONLY=1` defines the functions and
14
21
  * returns, `MEMGUARD_ONCE=1` runs one tick and exits, `MEMGUARD_NO_NOTIFY=1` skips the notification,
15
- * `MEMGUARD_ONLY_PID=<pid>` restricts kills to one process so a test cannot shoot its own runner.
22
+ * `MEMGUARD_ONLY_PID=<pid>` restricts kills to one process so a test cannot shoot its own runner,
23
+ * `MEMGUARD_SWAP_USED_MB` and `MEMGUARD_PRESSURE_LEVEL` replace the swap and pressure readings, `MEMGUARD_SCAN_TIMEOUT_S` the scan deadline and
24
+ * `MEMGUARD_FOOTPRINT_BIN` the footprint tool (a test points it at a stalling fake).
16
25
  */
17
26
  export function renderDarwinMemoryGuardScript(options) {
18
27
  const kindChecks = MEMORY_GUARD_KIND_PATTERNS.map(([kind, pattern]) => ` re=${shellQuote(pattern)}; [[ "$1" =~ $re ]] && { echo ${kind}; return; }`);
@@ -23,22 +32,63 @@ export function renderDarwinMemoryGuardScript(options) {
23
32
  `LIMIT_MB=${options.limits.hard}`,
24
33
  `WARN_LIMIT_MB=${options.limits.warning}`,
25
34
  `CRITICAL_LIMIT_MB=${options.limits.critical}`,
35
+ `SWAP_LIMIT_MB=${options.swapLimitMb ?? MEMORY_GUARD_DEFAULT_SWAP_LIMIT_MB}`,
26
36
  `ORPHAN_MIN_AGE_S=${MEMORY_GUARD_ORPHAN_MIN_AGE_SECONDS}`,
27
37
  `LOG=${shellQuote(options.logPath)}`,
28
38
  `LOG_LIMIT_BYTES=${MEMORY_GUARD_LOG_LIMIT_BYTES}`,
29
39
  `LOG_KEEP_LINES=${MEMORY_GUARD_LOG_KEEP_LINES}`,
30
- 'mkdir -p "$(dirname "$LOG")"',
40
+ "mkdir -p \"$(dirname \"$LOG\")\"",
41
+ "SCAN_TIMEOUT_S=\"${MEMGUARD_SCAN_TIMEOUT_S:-4}\"",
42
+ "FOOTPRINT_BIN=\"${MEMGUARD_FOOTPRINT_BIN:-/usr/bin/footprint}\"",
43
+ "HOT_RSS_MB=128",
44
+ "HOT_FOOTPRINT_MB=512",
45
+ "SWAP_NODE_FLOOR_MB=512",
46
+ "MAX_SCAN_PIDS=64",
47
+ "T=\"$(mktemp -d \"${TMPDIR:-/tmp}/memguard.XXXXXX\")\"",
48
+ "trap 'rm -rf \"$T\"' EXIT",
49
+ "HOT_PIDS=\"\"",
50
+ "NODE_ROWS=\"\"",
51
+ "FP_ROWS=\"\"",
52
+ "SWAP_USED_MB=0",
53
+ "KILLED=\" \"",
54
+ "LAST_TIMEOUT_LOG=-60",
55
+ "LAST_SWAP_LOG=-600",
56
+ "tick=0",
31
57
  "",
32
58
  "now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }",
33
59
  "",
34
60
  "# event pid footprint_mb limit_mb reason kind cmd_sha256. Every value is a number or a fixed token.",
35
61
  "log_json() {",
36
- ` printf '{"ts":"%s","event":"%s","pid":%s,"footprint_mb":%s,"limit_mb":%s,"reason":"%s","kind":"%s","cmd_sha256":"%s"}\\n' \\`,
37
- ' "$(now_iso)" "$1" "$2" "$3" "$4" "$5" "$6" "$7" >> "$LOG"',
62
+ " printf '{\"ts\":\"%s\",\"event\":\"%s\",\"pid\":%s,\"footprint_mb\":%s,\"limit_mb\":%s,\"reason\":\"%s\",\"kind\":\"%s\",\"cmd_sha256\":\"%s\"}\\n' \\",
63
+ " \"$(now_iso)\" \"$1\" \"$2\" \"$3\" \"$4\" \"$5\" \"$6\" \"$7\" >> \"$LOG\"",
38
64
  "}",
39
65
  "",
40
66
  "notify() { [ \"${MEMGUARD_NO_NOTIFY:-}\" = 1 ] || osascript -e \"display notification \\\"$1\\\" with title \\\"Tower\\\"\" >/dev/null 2>&1 & }",
41
67
  "",
68
+ "# bounded SECONDS OUTFILE COMMAND...: run COMMAND with its stdout in OUTFILE and a hard deadline. A tool that",
69
+ "# stalls (top, ps and sysctl all can, when a runaway has the machine swapping) is killed, never waited on,",
70
+ "# and the answer is 124. Without this one stalled call freezes every check after it.",
71
+ "bounded() {",
72
+ " local secs=\"$1\" out=\"$2\" pid n=0 max",
73
+ " shift 2",
74
+ " max=$((secs * 20))",
75
+ " \"$@\" >\"$out\" 2>/dev/null </dev/null &",
76
+ " pid=$!",
77
+ " while kill -0 \"$pid\" 2>/dev/null; do",
78
+ " if [ \"$n\" -ge \"$max\" ]; then kill -9 \"$pid\" 2>/dev/null; return 124; fi",
79
+ " sleep 0.05",
80
+ " n=$((n + 1))",
81
+ " done",
82
+ " wait \"$pid\" 2>/dev/null",
83
+ "}",
84
+ "",
85
+ "# A stalled scan is a finding, so it is logged (at most once per 30 s) with the tool that stalled.",
86
+ "log_timeout() { # tool",
87
+ " [ $((SECONDS - LAST_TIMEOUT_LOG)) -ge 30 ] || return 0",
88
+ " LAST_TIMEOUT_LOG=$SECONDS",
89
+ " log_json scan_timeout 0 0 0 \"scan_timeout_$1\" guard \"\"",
90
+ "}",
91
+ "",
42
92
  "# The coarse label for a command line. The same table as the Windows guard and the CLI's tests.",
43
93
  "classify_kind() {",
44
94
  " local re",
@@ -49,77 +99,178 @@ export function renderDarwinMemoryGuardScript(options) {
49
99
  "cmd_sha() { printf '%s' \"$1\" | shasum -a 256 | cut -c1-12; }",
50
100
  "",
51
101
  "to_mb() { # top MEM like 470M, 69G, 512K, 1024B, with an optional +/- suffix",
52
- ' local v="${1%[+-]}" n u',
53
- ' n="${v%[BKMGT]}"; u="${v: -1}"',
54
- ' case "$u" in B) echo $((n / 1048576)) ;; K) echo $((n / 1024)) ;; M) echo "$n" ;; G) echo $((n * 1024)) ;; T) echo $((n * 1048576)) ;; *) echo 0 ;; esac',
102
+ " local v=\"${1%[+-]}\" n u",
103
+ " n=\"${v%[BKMGT]}\"; u=\"${v: -1}\"",
104
+ " case \"$u\" in B) echo $((n / 1048576)) ;; K) echo $((n / 1024)) ;; M) echo \"$n\" ;; G) echo $((n * 1024)) ;; T) echo $((n * 1048576)) ;; *) echo 0 ;; esac",
55
105
  "}",
56
106
  "",
57
107
  "kill_node() { # pid footprint_mb limit_mb reason",
58
- ' local cmd kind sha result',
59
- ' [ "$1" = "$$" ] && return',
60
- ' [ -n "${MEMGUARD_ONLY_PID:-}" ] && [ "$1" != "$MEMGUARD_ONLY_PID" ] && return',
61
- ' cmd="$(ps -o command= -p "$1" 2>/dev/null | cut -c1-2000)"',
62
- ' kind="$(classify_kind "$cmd")"; sha="$(cmd_sha "$cmd")"',
108
+ " local cmd kind sha result",
109
+ " [ \"$1\" = \"$$\" ] && return",
110
+ " [ -n \"${MEMGUARD_ONLY_PID:-}\" ] && [ \"$1\" != \"$MEMGUARD_ONLY_PID\" ] && return",
111
+ " case \"$KILLED\" in *\" $1 \"*) return ;; esac",
112
+ " bounded 2 \"$T/cmd\" ps -o command= -p \"$1\"",
113
+ " cmd=\"$(cut -c1-2000 \"$T/cmd\" 2>/dev/null)\"",
114
+ " kind=\"$(classify_kind \"$cmd\")\"; sha=\"$(cmd_sha \"$cmd\")\"",
63
115
  " kill -9 \"$1\" 2>/dev/null && result=killed || result=kill_failed",
64
- ' log_json "$result" "$1" "$2" "$3" "$4" "$kind" "$sha"',
65
- ' if [ "$result" = killed ]; then',
66
- ' if [ "$2" -ge 1024 ]; then notify "Tower killed a runaway node process ($(( ($2 + 512) / 1024 )) GB, $kind)"; else notify "Tower killed a runaway node process ($2 MB, $kind)"; fi',
116
+ " KILLED=\"$KILLED$1 \"",
117
+ " log_json \"$result\" \"$1\" \"$2\" \"$3\" \"$4\" \"$kind\" \"$sha\"",
118
+ " if [ \"$result\" = killed ]; then",
119
+ " if [ \"$2\" -ge 1024 ]; then notify \"Tower killed a runaway node process ($(( ($2 + 512) / 1024 )) GB, $kind)\"; else notify \"Tower killed a runaway node process ($2 MB, $kind)\"; fi",
67
120
  " fi",
68
121
  "}",
69
122
  "",
70
123
  "# 1 normal, 2 warning, 4 critical",
71
- "pressure_level() { sysctl -n kern.memorystatus_vm_pressure_level 2>/dev/null || echo 1; }",
124
+ "pressure_level() { # MEMGUARD_PRESSURE_LEVEL is the test seam",
125
+ " local rc",
126
+ " if [ -n \"${MEMGUARD_PRESSURE_LEVEL:-}\" ]; then echo \"$MEMGUARD_PRESSURE_LEVEL\"; return; fi",
127
+ " bounded 2 \"$T/level\" sysctl -n kern.memorystatus_vm_pressure_level; rc=$?",
128
+ " [ \"$rc\" -eq 124 ] && log_timeout sysctl",
129
+ " if [ \"$rc\" -eq 0 ]; then cat \"$T/level\"; else echo 1; fi",
130
+ "}",
131
+ "",
132
+ "# MB of swap in use; 0 when unreadable. MEMGUARD_SWAP_USED_MB is the test seam.",
133
+ "swap_used_mb() {",
134
+ " local rc",
135
+ " if [ -n \"${MEMGUARD_SWAP_USED_MB:-}\" ]; then echo \"$MEMGUARD_SWAP_USED_MB\"; return; fi",
136
+ " bounded 2 \"$T/swap\" sysctl -n vm.swapusage; rc=$?",
137
+ " [ \"$rc\" -eq 124 ] && log_timeout sysctl",
138
+ " [ \"$rc\" -eq 0 ] || { echo 0; return; }",
139
+ " awk '{for (i = 1; i <= NF; i++) if ($i == \"used\") { v = $(i + 2); u = substr(v, length(v)); n = v + 0; if (u == \"G\") n *= 1024; else if (u == \"K\") n /= 1024; printf \"%d\\n\", n; exit } }' \"$T/swap\"",
140
+ "}",
141
+ "",
142
+ "# Every node process as \"pid rss_mb\". RSS is only the cheap first look; footprint is the judge.",
143
+ "node_snapshot() {",
144
+ " local rc",
145
+ " bounded \"$SCAN_TIMEOUT_S\" \"$T/ps\" ps -axo pid=,rss=,comm=; rc=$?",
146
+ " if [ \"$rc\" -ne 0 ]; then [ \"$rc\" -eq 124 ] && log_timeout ps; NODE_ROWS=\"\"; return 1; fi",
147
+ " NODE_ROWS=\"$(awk '$3 ~ /(^|\\/)node$/ {print $1, int($2/1024)}' \"$T/ps\")\"",
148
+ "}",
149
+ "",
150
+ "# The pids to read footprint for, largest RSS first, at most MAX_SCAN_PIDS: \"all\" takes every node process,",
151
+ "# \"hot\" only the ones already big (RSS) or seen big on the last full sweep.",
152
+ "pick_pids() { # mode",
153
+ " echo \"$NODE_ROWS\" | awk -v mode=\"$1\" -v hot=\" $HOT_PIDS \" -v min=\"$HOT_RSS_MB\" 'NF == 2 && (mode == \"all\" || $2 >= min || index(hot, \" \" $1 \" \")) {print $2, $1}' |",
154
+ " sort -rn | head -n \"$MAX_SCAN_PIDS\" | awk '{printf \"%s \", $2}'",
155
+ "}",
72
156
  "",
73
- "footprint_scan() { # limit_mb reason",
74
- " top -l 1 -o mem -n 25 -stats pid,mem,command 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/' |",
75
- " while read -r pid mem comm _; do",
76
- ' case "$comm" in node) ;; *) continue ;; esac',
77
- ' mb="$(to_mb "$mem")"',
78
- ' [ "$mb" -gt "$1" ] 2>/dev/null && kill_node "$pid" "$mb" "$1" "$2"',
79
- " done",
157
+ "# footprint -p reads the kernel's phys_footprint (what Activity Monitor shows: dirty plus compressed plus",
158
+ "# swapped) in ~5 ms per process. FP_ROWS becomes \"pid mb\". Returns 124 on a stall, 1 when nothing parsed.",
159
+ "footprint_read() { # \"pid pid ...\"",
160
+ " local args=\"\" p rc",
161
+ " for p in $1; do args=\"$args -p $p\"; done",
162
+ " # shellcheck disable=SC2086",
163
+ " bounded \"$SCAN_TIMEOUT_S\" \"$T/fp\" \"$FOOTPRINT_BIN\" --noCategories -f bytes $args; rc=$?",
164
+ " FP_ROWS=\"\"",
165
+ " if [ \"$rc\" -eq 124 ]; then log_timeout footprint; return 124; fi",
166
+ " FP_ROWS=\"$(awk '/Footprint: [0-9]+ B/ && !/^Summary/ { i = index($0, \"[\"); j = index($0, \"]\"); if (i && j > i) { n = split($0, a, \"Footprint: \"); split(a[2], b, \" \"); printf \"%s %d\\n\", substr($0, i + 1, j - i - 1), b[1] / 1048576 } }' \"$T/fp\")\"",
167
+ " [ -n \"$FP_ROWS\" ]",
80
168
  "}",
81
169
  "",
82
- "rss_scan() { # limit_mb; the cheap every-tick check on resident memory alone",
83
- ` ps -axo pid=,rss=,comm= | awk -v lim="$(($1 * 1024))" '$3 ~ /(^|\\/)node$/ && $2 > lim {print $1, int($2/1024)}' |`,
84
- ' while read -r pid mb; do kill_node "$pid" "$mb" "$1" rss_over_limit; done',
170
+ "# The fallback when footprint is missing or unreadable: top's MEM column, node processes among the 25 biggest.",
171
+ "top_rows() {",
172
+ " local rc pid mem comm",
173
+ " bounded \"$SCAN_TIMEOUT_S\" \"$T/top\" top -l 1 -o mem -n 25 -stats pid,mem,command; rc=$?",
174
+ " FP_ROWS=\"\"",
175
+ " if [ \"$rc\" -eq 124 ]; then log_timeout top; return 124; fi",
176
+ " awk 'NR > 1 && $1 ~ /^[0-9]+$/' \"$T/top\" > \"$T/top.rows\"",
177
+ " while read -r pid mem comm _; do",
178
+ " case \"$comm\" in node) ;; *) continue ;; esac",
179
+ " FP_ROWS=\"$FP_ROWS$pid $(to_mb \"$mem\")",
180
+ "\"",
181
+ " done < \"$T/top.rows\"",
85
182
  "}",
86
183
  "",
184
+ "# Swap climbing past the limit with a big node process holding a quarter of it is a runaway in progress, even",
185
+ "# if every single footprint is under the limit (or no footprint could be read). Not node: say so, once per 5 min.",
186
+ "swap_check() { # biggest_pid biggest_mb",
187
+ " local quarter",
188
+ " [ \"$SWAP_USED_MB\" -ge \"$SWAP_LIMIT_MB\" ] 2>/dev/null || return 0",
189
+ " quarter=$((SWAP_USED_MB / 4)); [ \"$quarter\" -lt \"$SWAP_NODE_FLOOR_MB\" ] && quarter=$SWAP_NODE_FLOOR_MB",
190
+ " if [ \"$1\" -gt 0 ] && [ \"$2\" -ge \"$quarter\" ]; then",
191
+ " case \"$KILLED\" in *\" $1 \"*) return 0 ;; esac",
192
+ " kill_node \"$1\" \"$2\" \"$SWAP_LIMIT_MB\" swap_pressure",
193
+ " elif [ $((SECONDS - LAST_SWAP_LOG)) -ge 300 ]; then",
194
+ " LAST_SWAP_LOG=$SECONDS",
195
+ " log_json swap_high 0 \"$SWAP_USED_MB\" \"$SWAP_LIMIT_MB\" swap_high_not_node guard \"\"",
196
+ " fi",
197
+ "}",
198
+ "",
199
+ "# One pass: kill by RSS, then read footprint and kill by footprint, then the swap check.",
200
+ "guard_scan() { # mode(all|hot) limit_mb reason",
201
+ " local pid mb rc pids hot=\"\" biggest=0 biggest_mb=0",
202
+ " node_snapshot || return 0",
203
+ " while read -r pid mb; do",
204
+ " [ -n \"$pid\" ] || continue",
205
+ " [ \"$mb\" -gt \"$2\" ] 2>/dev/null && kill_node \"$pid\" \"$mb\" \"$2\" rss_over_limit",
206
+ " done <<< \"$NODE_ROWS\"",
207
+ " pids=\"$(pick_pids \"$1\")\"",
208
+ " if [ -n \"$pids\" ]; then",
209
+ " footprint_read \"$pids\"; rc=$?",
210
+ " [ \"$rc\" -eq 1 ] && top_rows",
211
+ " while read -r pid mb; do",
212
+ " [ -n \"$pid\" ] || continue",
213
+ " [ \"$mb\" -ge \"$HOT_FOOTPRINT_MB\" ] 2>/dev/null && hot=\"$hot$pid \"",
214
+ " [ \"$mb\" -gt \"$biggest_mb\" ] 2>/dev/null && { biggest=$pid; biggest_mb=$mb; }",
215
+ " [ \"$mb\" -gt \"$2\" ] 2>/dev/null && kill_node \"$pid\" \"$mb\" \"$2\" \"$3\"",
216
+ " done <<< \"$FP_ROWS\"",
217
+ " [ \"$1\" = all ] && HOT_PIDS=\"$hot\"",
218
+ " fi",
219
+ " if [ \"$biggest\" -eq 0 ]; then # no footprint could be read: the biggest RSS is the next best witness for the swap check",
220
+ " read -r biggest biggest_mb <<< \"$(echo \"$NODE_ROWS\" | sort -k2 -rn | head -n 1)\"",
221
+ " biggest=${biggest:-0}; biggest_mb=${biggest_mb:-0}",
222
+ " fi",
223
+ " swap_check \"$biggest\" \"$biggest_mb\"",
224
+ "}",
225
+ "",
226
+ "footprint_scan() { guard_scan all \"$1\" \"$2\"; } # limit_mb reason: one full pass over every node process",
227
+ "",
87
228
  "orphan_vitest_scan() { # vitest workers whose runner died (ppid 1) and that outlived the grace period",
88
- " ps -axo pid=,ppid=,etime=,command= | awk '$2 == 1 && $4 ~ /(^|\\/)node$/ && $0 ~ /vitest\\/dist\\/workers\\/forks|vitest\\/dist\\/workers\\/threads|node \\(vitest/ {print $1, $3}' |",
89
- " while read -r pid etime; do",
90
- ` secs="$(echo "$etime" | awk -F'[-:]' '{n=NF; s=$n+($(n-1))*60; if(n>=3)s+=$(n-2)*3600; if(n==4)s+=$1*86400; print s}')"`,
91
- ' [ "$secs" -ge "$ORPHAN_MIN_AGE_S" ] 2>/dev/null || continue',
92
- ' [ -n "${MEMGUARD_ONLY_PID:-}" ] && [ "$pid" != "$MEMGUARD_ONLY_PID" ] && continue',
93
- ' cmd="$(ps -o command= -p "$pid" 2>/dev/null | cut -c1-2000)"',
94
- ' kill -9 "$pid" 2>/dev/null && log_json orphan_reaped "$pid" 0 0 orphan_vitest_worker "$(classify_kind "$cmd")" "$(cmd_sha "$cmd")"',
95
- " done",
229
+ " local pid etime secs cmd rc",
230
+ " bounded \"$SCAN_TIMEOUT_S\" \"$T/orphans\" ps -axo pid=,ppid=,etime=,command=; rc=$?",
231
+ " if [ \"$rc\" -ne 0 ]; then [ \"$rc\" -eq 124 ] && log_timeout ps; return 0; fi",
232
+ " awk '$2 == 1 && $4 ~ /(^|\\/)node$/ && $0 ~ /vitest\\/dist\\/workers\\/forks|vitest\\/dist\\/workers\\/threads|node \\(vitest/ {print $1, $3}' \"$T/orphans\" > \"$T/orphans.rows\"",
233
+ " while read -r pid etime; do",
234
+ " secs=\"$(echo \"$etime\" | awk -F'[-:]' '{n=NF; s=$n+($(n-1))*60; if(n>=3)s+=$(n-2)*3600; if(n==4)s+=$1*86400; print s}')\"",
235
+ " [ \"$secs\" -ge \"$ORPHAN_MIN_AGE_S\" ] 2>/dev/null || continue",
236
+ " [ -n \"${MEMGUARD_ONLY_PID:-}\" ] && [ \"$pid\" != \"$MEMGUARD_ONLY_PID\" ] && continue",
237
+ " bounded 2 \"$T/cmd\" ps -o command= -p \"$pid\"",
238
+ " cmd=\"$(cut -c1-2000 \"$T/cmd\" 2>/dev/null)\"",
239
+ " kill -9 \"$pid\" 2>/dev/null && log_json orphan_reaped \"$pid\" 0 0 orphan_vitest_worker \"$(classify_kind \"$cmd\")\" \"$(cmd_sha \"$cmd\")\"",
240
+ " done < \"$T/orphans.rows\"",
96
241
  "}",
97
242
  "",
98
243
  "trim_log() { # keep the log small: past the cap, keep the newest lines",
99
- ' local size; size="$(wc -c < "$LOG" 2>/dev/null | tr -d \' \')"',
100
- ' [ "${size:-0}" -gt "$LOG_LIMIT_BYTES" ] 2>/dev/null || return 0',
101
- ' tail -n "$LOG_KEEP_LINES" "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG"',
244
+ " local size; size=\"$(wc -c < \"$LOG\" 2>/dev/null | tr -d ' ')\"",
245
+ " [ \"${size:-0}\" -gt \"$LOG_LIMIT_BYTES\" ] 2>/dev/null || return 0",
246
+ " tail -n \"$LOG_KEEP_LINES\" \"$LOG\" > \"$LOG.tmp\" 2>/dev/null && mv \"$LOG.tmp\" \"$LOG\"",
247
+ "}",
248
+ "",
249
+ "# One tick. The limit tightens with memory pressure. Every tick judges the node processes that are big or were",
250
+ "# big; every fifth tick (10 s; every tick under pressure or high swap) it judges all of them.",
251
+ "guard_tick() {",
252
+ " local level limit reason mode",
253
+ " level=\"$(pressure_level)\"",
254
+ " limit=$LIMIT_MB; reason=footprint_over_limit",
255
+ " if [ \"$level\" -ge 4 ] 2>/dev/null; then limit=$CRITICAL_LIMIT_MB; reason=pressure_critical",
256
+ " elif [ \"$level\" -ge 2 ] 2>/dev/null; then limit=$WARN_LIMIT_MB; reason=pressure_warning; fi",
257
+ " SWAP_USED_MB=\"$(swap_used_mb)\"",
258
+ " mode=hot",
259
+ " [ $((tick % 5)) -eq 0 ] && mode=all",
260
+ " [ \"$level\" -ge 2 ] 2>/dev/null && mode=all",
261
+ " [ \"$SWAP_USED_MB\" -ge \"$SWAP_LIMIT_MB\" ] 2>/dev/null && mode=all",
262
+ " KILLED=\" \"",
263
+ " guard_scan \"$mode\" \"$limit\" \"$reason\"",
102
264
  "}",
103
265
  "",
104
- '[ "${MEMGUARD_SOURCE_ONLY:-}" = 1 ] && return 0 2>/dev/null',
266
+ "[ \"${MEMGUARD_SOURCE_ONLY:-}\" = 1 ] && return 0 2>/dev/null",
105
267
  "",
106
- 'log_json started "$$" 0 "$LIMIT_MB" started guard ""',
107
- "tick=0",
268
+ "log_json started \"$$\" 0 \"$LIMIT_MB\" started guard \"\"",
108
269
  "while :; do",
109
- ' level="$(pressure_level)"',
110
- ' if [ "$level" -ge 4 ] 2>/dev/null; then',
111
- ' rss_scan "$CRITICAL_LIMIT_MB"',
112
- ' footprint_scan "$CRITICAL_LIMIT_MB" pressure_critical',
113
- ' elif [ "$level" -ge 2 ] 2>/dev/null; then',
114
- ' rss_scan "$WARN_LIMIT_MB"',
115
- ' [ $((tick % 5)) -eq 0 ] && footprint_scan "$WARN_LIMIT_MB" pressure_warning',
116
- " else",
117
- ' rss_scan "$LIMIT_MB"',
118
- ' [ $((tick % 5)) -eq 0 ] && footprint_scan "$LIMIT_MB" footprint_over_limit',
119
- " fi",
120
- ' [ $((tick % 30)) -eq 0 ] && orphan_vitest_scan',
121
- ' [ $((tick % 30)) -eq 15 ] && trim_log',
122
- ' [ "${MEMGUARD_ONCE:-}" = 1 ] && exit 0',
270
+ " guard_tick",
271
+ " [ $((tick % 30)) -eq 0 ] && orphan_vitest_scan",
272
+ " [ $((tick % 30)) -eq 15 ] && trim_log",
273
+ " [ \"${MEMGUARD_ONCE:-}\" = 1 ] && exit 0",
123
274
  " tick=$((tick + 1))",
124
275
  " sleep 2",
125
276
  "done",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.283",
3
+ "version": "0.2.285",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.88",
31
- "@bli-cockpit/mcp": "0.1.172",
31
+ "@bli-cockpit/mcp": "0.1.173",
32
32
  "@bli-cockpit/telemetry-core": "0.1.94",
33
33
  "unpdf": "1.8.1"
34
34
  },