@bli-cockpit/cli 0.2.282 → 0.2.284

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
  }
@@ -55,6 +55,7 @@ const NOTES_ACTIONS = new Set([
55
55
  "bot-upcoming",
56
56
  "record",
57
57
  "app",
58
+ "review",
58
59
  ]);
59
60
  /** Actions whose first positional is the note it acts on. */
60
61
  const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
@@ -110,6 +111,9 @@ const NOTES_ALLOWED_FLAGS = [
110
111
  // transcript was stored cut short. A FLAG, not a verb: it is the same sync
111
112
  // door with a different request.
112
113
  "--repair-truncated",
114
+ // BLI-22350. `review <note-id> --submit` reads `{ raw_hash, answers }` from stdin; `--accept-all` sends the defaults.
115
+ "--submit",
116
+ "--accept-all",
113
117
  ];
114
118
  const NOTES_VALUE_FLAGS = [
115
119
  "--home",
@@ -150,6 +154,7 @@ export function parseNotesArgs(args) {
150
154
  throw new Error("notes rename needs --name <name>.");
151
155
  const { to, clearShelf } = readNotesShelfTarget(action, values);
152
156
  const repairTruncated = readRepairTruncated(action, values);
157
+ const review = readReviewRequest(action, values, rest);
153
158
  const { limit, month, offset } = readNotesWindow(action, values);
154
159
  return {
155
160
  kind: "notes",
@@ -184,6 +189,7 @@ export function parseNotesArgs(args) {
184
189
  ...(month ? { month } : {}),
185
190
  ...(offset === undefined ? {} : { offset }),
186
191
  ...(repairTruncated ? { repairTruncated: true } : {}),
192
+ ...review,
187
193
  yes,
188
194
  json,
189
195
  };
@@ -193,7 +199,7 @@ function readNotesAction(first) {
193
199
  // almost always wants — same reasoning as bare `cockpit` running convergence.
194
200
  const action = (first === undefined ? "list" : first);
195
201
  if (!NOTES_ACTIONS.has(action)) {
196
- throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, or transcription-stats (and record start|stop|status|cancel, app install|uninstall|status).`);
202
+ throw new Error(`Unknown notes command: ${first}. Try list, show, shelf, shelves, upload, paste, share, unshare, summarize, move, place, accounts, connect, detach, sync, sync-status, recordings, recording, webhook, granola-status, transcription-stats, or review (and record start|stop|status|cancel, app install|uninstall|status).`);
197
203
  }
198
204
  return action;
199
205
  }
@@ -220,6 +226,23 @@ function readAppRequest(rest) {
220
226
  throw new Error(`notes app ${verb} does not take "${rest[1]}".`);
221
227
  return { appAction: verb };
222
228
  }
229
+ /** BLI-22350: `review [<note-id>] [--submit|--accept-all]`. Bare lists mine; an id shows one; a flag answers it. */
230
+ function readReviewRequest(action, values, rest) {
231
+ const flag = values.booleans.has("--submit") ? "--submit" : values.booleans.has("--accept-all") ? "--accept-all" : null;
232
+ if (action !== "review") {
233
+ if (flag)
234
+ throw new Error(`${flag} belongs to \`cockpit notes review <note-id> ${flag}\`.`);
235
+ return {};
236
+ }
237
+ if (rest.length > 1)
238
+ throw new Error(`notes review takes one note id, not ${rest.length}.`);
239
+ const noteId = optionalNonEmpty(rest[0]);
240
+ if (values.booleans.has("--submit") && values.booleans.has("--accept-all"))
241
+ throw new Error("notes review takes --submit or --accept-all, not both.");
242
+ if (flag && !noteId)
243
+ throw new Error(`notes review ${flag} needs a note id: cockpit notes review <note-id> ${flag}.`);
244
+ return { ...(noteId ? { noteId } : {}), ...(flag === "--submit" ? { submit: true } : {}), ...(flag === "--accept-all" ? { acceptAll: true } : {}) };
245
+ }
223
246
  function readRepairTruncated(action, values) {
224
247
  const repairTruncated = values.booleans.has("--repair-truncated");
225
248
  if (repairTruncated && action !== "sync") {
@@ -328,6 +351,7 @@ function readNotesPaths(action, rest) {
328
351
  const takesNoPositional = action !== "paste"
329
352
  && action !== "record"
330
353
  && action !== "app"
354
+ && action !== "review"
331
355
  && action !== "connect"
332
356
  && action !== "sync"
333
357
  && action !== "sync-status"
@@ -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).",
@@ -3,7 +3,7 @@ export const NOTES_HELP = [
3
3
  [
4
4
  "notes",
5
5
  [
6
- "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status] [flags]",
6
+ "Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|summarize <id>|move <id>|accounts|connect|detach <id>|sync <id>|sync-status [<id>]|recordings <note-id>|recording <id>|record start|stop|status|cancel|app install|uninstall|status|review [<note-id>] [--submit|--accept-all]] [flags]",
7
7
  "Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 1 GB. Over 4 MB (a whole meeting) the file goes straight to storage and is transcribed in the background; --wait then polls for up to 4 hours and prints progress, otherwise for at most 330 seconds.",
8
8
  "",
9
9
  "record start|stop [<session-id>]|status|cancel — record a meeting in a room from this machine's microphone (Apple Silicon macOS and Windows; Linux says it is not supported). On Windows the microphone needs Settings > Privacy & security > Microphone on, including Let desktop apps access your microphone. Nothing leaves the machine while recording; the recorder stops by itself after 8 hours (under the 1 GB upload limit). `stop` uploads the whole original recording, silence included, and Tower writes a note with Speaker 1, Speaker 2... lines, or says no speech was detected (add --wait to watch the transcription). `cancel` stops and KEEPS the file; `status` lists what is recording and every session waiting for upload (`needs_upload`); `stop <session-id>` uploads a cancelled, crashed or auto-stopped session. The BLI Notetaker app uploads waiting sessions by itself. The recording also stays on this machine.",
@@ -42,6 +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, 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.",
45
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.",
46
47
  "--json writes one machine-readable object to stdout; every reason, receipt and progress line stays on stderr.",
47
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.",
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `cockpit notes review [<note-id>] [--submit|--accept-all] [--json]` (BLI-22350): the attendee's meeting review in
3
+ * the terminal. Sibling of `notes.ts`, named in its header. Owner doc: `docs/runbooks/meeting-review-screen.md`.
4
+ *
5
+ * review the reviews waiting for me (GET /api/notes/review)
6
+ * review <id> one review, in the screen's order (GET /api/notes/review/<id>); --json is the raw read
7
+ * review <id> --submit `{ raw_hash, answers }` JSON from stdin (POST /api/notes/review/<id>)
8
+ * review <id> --accept-all read, then submit every item's default answer, and print what it accepted
9
+ *
10
+ * The same doors the web screen and the `notes_review_*` MCP tools call; the database decides who is a reviewer. This
11
+ * file holds no rule about answers: the door checks them and its refusal (reason and sentence) is relayed verbatim.
12
+ */
13
+ import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
14
+ import { ask, emit, errorText, fail, READ_DEADLINE_MS } from "./notes-door.js";
15
+ const WRITE_DEADLINE_MS = 60_000;
16
+ /** A review's answers are a few dozen short objects; this is far above any real one. */
17
+ const SUBMIT_MAX_CHARS = 512_000;
18
+ /** The dashboard's `review-words.ts` sentence, repeated here: this package does not import the dashboard. */
19
+ const AS_TRANSCRIBED_LINE = "This meeting is kept as transcribed; only the speakers need confirming.";
20
+ /** The web screen's plain label per meaning-check issue, repeated here for the same reason. */
21
+ const MEANING_ISSUE_WORDS = {
22
+ number: "A number may have changed",
23
+ negation_or_pronoun: "A 'not' or a 'we/you' may have flipped",
24
+ hedge_or_correction: "A 'maybe' or a correction may have been dropped",
25
+ added_words: "Words may have been added",
26
+ unclear_made_sensible: "An unclear bit may have been made to sound sensible",
27
+ speaker_moved: "A line may have been moved to another speaker",
28
+ passage_left_out: "A passage may have been left out",
29
+ other: "The meaning may have changed",
30
+ };
31
+ const STATE_WORDS = {
32
+ queued: "waiting for Tower to prepare it",
33
+ preparing: "being prepared (transcript cleaned and checked)",
34
+ awaiting_attendee: "waiting for your answers",
35
+ applying: "answered; Tower is applying the answers",
36
+ cleared: "cleared",
37
+ failed: "stuck; an engineer has been told",
38
+ };
39
+ const reviewPath = (noteId) => `/api/notes/review/${encodeURIComponent(noteId)}`;
40
+ export async function runNotesReview(command, door) {
41
+ if (!command.noteId)
42
+ return listReviews(door);
43
+ if (command.submit)
44
+ return submitFromStdin(command.noteId, door);
45
+ if (command.acceptAll)
46
+ return acceptAll(command.noteId, door);
47
+ return showReview(command.noteId, door);
48
+ }
49
+ async function listReviews(door) {
50
+ const answer = await ask(door, { path: "/api/notes/review", method: "GET", label: "notes review", timeoutMs: READ_DEADLINE_MS });
51
+ if (!answer.ok)
52
+ return fail(door, answer.reason, answer.detail);
53
+ const body = answer.body;
54
+ if (door.json)
55
+ return emit(door, { ok: true, ...body });
56
+ const out = door.io.stdout;
57
+ if (body.reviews.length === 0) {
58
+ writeLine(out, "No meeting reviews for you: nothing waits, and nothing was cleared in the last 7 days.");
59
+ return 0;
60
+ }
61
+ writeLine(out, `${body.waiting} meeting(s) wait for your review.`);
62
+ for (const row of body.reviews) {
63
+ writeLine(out, `${row.note_id} ${row.meeting_date} ${row.item_count} item(s) ${STATE_WORDS[row.state] ?? row.state} ${row.note_title ?? "(untitled meeting)"}`);
64
+ }
65
+ writeLine(out, `Open one: cockpit notes review <note-id>, or ${door.dashboardUrl}/meeting-notes/<note-id>/review in a browser.`);
66
+ return 0;
67
+ }
68
+ async function readOne(noteId, door) {
69
+ return ask(door, { path: reviewPath(noteId), method: "GET", label: "notes review", timeoutMs: READ_DEADLINE_MS });
70
+ }
71
+ function lines(value) {
72
+ if (value === null || value === undefined)
73
+ return [];
74
+ if (typeof value === "string")
75
+ return value.trim() ? [value.trim()] : [];
76
+ if (Array.isArray(value))
77
+ return value.flatMap((entry) => (typeof entry === "string" ? [entry] : [JSON.stringify(entry)]));
78
+ return [JSON.stringify(value)];
79
+ }
80
+ /**
81
+ * The review in the screen's order: who's who, lines under the wrong speaker, unclear words, meaning checks,
82
+ * sensitive moments.
83
+ */
84
+ /** A meaning check's stretch is one or more transcript lines: the label on the first, the rest indented under it. */
85
+ function stretch(label, text) {
86
+ const [first = "", ...rest] = text.split(/\r?\n/);
87
+ const pad = " ".repeat(label.length + 5);
88
+ return [` ${label} ${first}`, ...rest.map((line) => `${pad}${line}`)];
89
+ }
90
+ export function renderReview(body, dashboardUrl) {
91
+ const out = [];
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);
96
+ const of = (kind) => body.items.filter((item) => item.kind === kind);
97
+ const speakers = of("speaker");
98
+ if (speakers.length)
99
+ out.push("", "Who's who (the name you confirm or correct):");
100
+ for (const item of speakers) {
101
+ out.push(` ${item.label}: ${item.guess || "(no guess; leave empty to keep the label)"} [${item.id}]`, ` why: ${item.reasoning}`);
102
+ for (const quote of item.quotes ?? [])
103
+ out.push(` ${quote.timestamp} "${quote.text}"`);
104
+ }
105
+ const moved = of("speaker_line");
106
+ if (moved.length)
107
+ out.push("", "Lines that may be under the wrong speaker (default: keep):");
108
+ for (const item of moved)
109
+ out.push(` ${item.timestamp} under ${item.guess || "(no speaker)"}: "${item.quote.text}" [${item.id}]`);
110
+ const unclear = of("unclear");
111
+ if (unclear.length)
112
+ out.push("", "Unclear words (default: the best guess):");
113
+ for (const item of unclear) {
114
+ out.push(` ${item.timestamp} "${item.words}" -> ${item.guess || "(keep marked unclear)"}${item.changesMeaning ? " CHANGES THE MEANING" : ""} [${item.id}]`, ` context: ${item.context}`);
115
+ }
116
+ const meaning = of("meaning_check");
117
+ if (meaning.length)
118
+ out.push("", "Did the clean-up change the meaning? (default: keep the words as said; answer \"raw\" or \"cleaned\"):");
119
+ for (const item of meaning) {
120
+ out.push(` ${item.timestamp} ${MEANING_ISSUE_WORDS[item.issue] ?? MEANING_ISSUE_WORDS.other} [${item.id}]`);
121
+ if (item.said || item.written)
122
+ out.push(` "${item.said}" -> "${item.written}"`);
123
+ out.push(...stretch("as said: ", item.raw), ...stretch("as cleaned up:", item.cleaned));
124
+ }
125
+ const sensitive = of("sensitive");
126
+ if (sensitive.length)
127
+ out.push("", "Sensitive moments (default: attendees only):");
128
+ for (const item of sensitive)
129
+ out.push(` ${item.timestamp} ${item.what} [${item.id}]`);
130
+ if (body.as_transcribed)
131
+ out.push("", AS_TRANSCRIBED_LINE);
132
+ else {
133
+ for (const [heading, value] of [["Summary", body.summary], ["Decisions", body.decisions], ["Action items", body.action_items]]) {
134
+ const said = lines(value);
135
+ if (said.length)
136
+ out.push("", `${heading}:`, ...said.map((line) => ` ${line}`));
137
+ }
138
+ }
139
+ if (body.state === "cleared" && body.attendee_notes)
140
+ out.push("", "Kept for the attendees only:", ...body.attendee_notes.split("\n").map((line) => ` ${line}`));
141
+ if (body.state === "awaiting_attendee") {
142
+ out.push("", `Answer: cockpit notes review ${body.note_id} --accept-all (every default above), or edit the defaults from`, `cockpit notes review ${body.note_id} --json and pipe { "raw_hash", "answers" } to cockpit notes review ${body.note_id} --submit.`, `Or open ${dashboardUrl}/meeting-notes/${body.note_id}/review.`);
143
+ }
144
+ return out;
145
+ }
146
+ async function showReview(noteId, door) {
147
+ const answer = await readOne(noteId, door);
148
+ if (!answer.ok)
149
+ return fail(door, answer.reason, answer.detail);
150
+ if (door.json)
151
+ return emit(door, answer.body);
152
+ for (const line of renderReview(answer.body, door.dashboardUrl))
153
+ writeLine(door.io.stdout, line);
154
+ return 0;
155
+ }
156
+ async function post(noteId, door, body, accepted) {
157
+ const answer = await ask(door, { path: reviewPath(noteId), method: "POST", label: "notes review submit", timeoutMs: WRITE_DEADLINE_MS, body });
158
+ if (!answer.ok)
159
+ return fail(door, answer.reason, answer.detail);
160
+ const done = answer.body;
161
+ if (door.json)
162
+ return emit(door, { ok: true, ...done, ...(accepted ? { accepted } : {}) });
163
+ writeLine(door.io.stdout, `Submitted ${done.answers} answer(s). Tower is applying them; the meeting is released to the team once its privacy check passes.`);
164
+ return 0;
165
+ }
166
+ async function submitFromStdin(noteId, door) {
167
+ if (isInteractiveStdin(door.io)) {
168
+ return fail(door, "nothing_piped", `Pipe { "raw_hash": ..., "answers": [...] } in: cockpit notes review ${noteId} --json, edit the defaults, then pipe them to cockpit notes review ${noteId} --submit.`);
169
+ }
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: SUBMIT_MAX_CHARS, overflowMessage: "The answers piped in are far larger than any review." }));
173
+ }
174
+ catch (error) {
175
+ return fail(door, "invalid_body", `The answers piped in are not JSON (${errorText(error)}). Send { "raw_hash": ..., "answers": [...] }.`);
176
+ }
177
+ return post(noteId, door, parsed);
178
+ }
179
+ function acceptedWords(answer) {
180
+ const value = answer.answer;
181
+ if (answer.kind === "speaker")
182
+ return `speaker ${answer.guess || "(no guess)"} -> ${value === "" ? "keep the label" : String(value)}`;
183
+ if (answer.kind === "speaker_line")
184
+ return "line stays where it is";
185
+ if (answer.kind === "unclear") {
186
+ const reading = value.reading;
187
+ return reading === null ? "unclear word stays marked" : `unclear word -> ${reading}`;
188
+ }
189
+ if (answer.kind === "meaning_check")
190
+ return value === "cleaned" ? "meaning check -> keep the clean-up's words" : "meaning check -> keep the words as said";
191
+ return `sensitive moment -> ${String(value) === "keep_in" ? "kept in" : "attendees only"}`;
192
+ }
193
+ async function acceptAll(noteId, door) {
194
+ const read = await readOne(noteId, door);
195
+ if (!read.ok)
196
+ return fail(door, read.reason, read.detail);
197
+ const review = read.body;
198
+ if (review.state !== "awaiting_attendee") {
199
+ return fail(door, "review_not_awaiting_attendee", `This review is ${STATE_WORDS[review.state] ?? review.state}, so there is nothing to answer.`);
200
+ }
201
+ if (!door.json) {
202
+ writeLine(door.io.stdout, `Accepting ${review.defaults.length} default answer(s):`);
203
+ for (const answer of review.defaults)
204
+ writeLine(door.io.stdout, ` ${answer.item_id}: ${acceptedWords(answer)}`);
205
+ }
206
+ return post(noteId, door, { raw_hash: review.raw_hash, answers: review.defaults }, review.defaults);
207
+ }
@@ -40,6 +40,8 @@
40
40
  * Notetaker app on this Mac or Windows PC (Mac LaunchAgent: `notes-app-plist.ts`; Windows: `notes-app-windows.ts`).
41
41
  * - `notes-transcription-stats.ts` — BLI-18003: what meeting transcription
42
42
  * cost and did per provider for a month, and the spend against the cap.
43
+ * - `notes-review.ts` — BLI-22350: the meeting reviews waiting for me, one
44
+ * review, and answering it (`--submit` from stdin, `--accept-all`).
43
45
  *
44
46
  * Every public name is still importable from this file.
45
47
  */
@@ -52,6 +54,7 @@ import { armNotesWebhook, connectNotetaker, detachNotetaker, listNotesAccounts,
52
54
  import { showBotUpcoming } from "./notes-bot-upcoming.js";
53
55
  import { showGranolaStatus } from "./notes-granola-status.js";
54
56
  import { showTranscriptionStats } from "./notes-transcription-stats.js";
57
+ import { runNotesReview } from "./notes-review.js";
55
58
  import { listRecordings, showRecording } from "./notes-recordings.js";
56
59
  import { defaultRecordDeps, runNotesRecord } from "./notes-record.js";
57
60
  import { runNotesApp } from "./notes-app.js";
@@ -94,6 +97,7 @@ const NOTES_VERBS = {
94
97
  "bot-upcoming": showBotUpcoming,
95
98
  "granola-status": showGranolaStatus,
96
99
  "transcription-stats": showTranscriptionStats,
100
+ review: runNotesReview,
97
101
  };
98
102
  export async function runNotes(command, io) {
99
103
  // BLI-22979: recording is local until `stop` uploads, so only the upload needs the paired session.
@@ -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.282");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.284");
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.282",
3
+ "version": "0.2.284",
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.171",
31
+ "@bli-cockpit/mcp": "0.1.173",
32
32
  "@bli-cockpit/telemetry-core": "0.1.94",
33
33
  "unpdf": "1.8.1"
34
34
  },