beatrina 0.8.6 → 0.9.43

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.
Files changed (112) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +156 -26
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +271 -35
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
package/host/server.mjs CHANGED
@@ -30,13 +30,18 @@ import http from "node:http";
30
30
  import path from "node:path";
31
31
  import { attachWebSocket } from "./ws.mjs";
32
32
  import { rookOf, proxyUser } from "./deployment.mjs";
33
+ import { cleanSessionDocuments } from "./session-keep.mjs";
34
+ import { readEnv } from "./env-names.mjs";
35
+ import { gatewayCommands, gatewayTokenGate, GATEWAY_MANAGED_COMMANDS, GATEWAY_MANAGED_PATHS, GATEWAY_HIDDEN_CAPABILITIES } from "./gateway-token.mjs";
33
36
 
34
- export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
37
+ export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "column_values", "rm", "packages", "doctor",
35
38
  "package_action", "package_help", "project_status", "project_action", "help", "wd",
36
39
  "parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
37
- "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"]);
38
- export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
39
- "input_reply", "debug_cmd"]);
40
+ "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
41
+ // "Restart, keep variables", step one (spike/workspace-keep.R). Page-only in both senses: AGENT_REFUSED
42
+ // below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
43
+ // host asks it, after a restart it performed (WorkerPlane.startResume).
44
+ "suspend"]);
40
45
  /** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
41
46
  export const NOT_YET = Object.freeze({
42
47
  files: "the file system", journal: "the document journal", ai: "the AI conversation store",
@@ -48,12 +53,19 @@ export const NOT_YET = Object.freeze({
48
53
  "open-request": "the open-file door", "page-title": "", "session-new": "session management",
49
54
  cite_styles: "citations",
50
55
  });
51
- export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"]);
56
+ export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
57
+ // A copy of every object in the session, written to disk.
58
+ "suspend", "suspend_cancel",
59
+ // The grid's clipboard door (a whole column, up to a million values); an agent reads data through a chunk.
60
+ "column_values"]);
52
61
  /** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
53
62
  export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
54
63
  export const AGENT_WHY = Object.freeze({
55
64
  input_reply: "Agents cannot answer a prompt on the user's behalf.",
56
65
  project_action: "Agents cannot install or restore project packages.",
66
+ suspend: "Agents cannot keep the user's workspace.",
67
+ suspend_cancel: "Agents cannot keep the user's workspace.",
68
+ column_values: "Agents read data through notebook chunks (chunk_run), not the grid's copy door.",
57
69
  });
58
70
  export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
59
71
  export const FILE_ORIGIN = "null";
@@ -79,9 +91,16 @@ export function secureToken(n = 32) {
79
91
  return out;
80
92
  }
81
93
 
82
- /** Stamp the served HTML with this session's identity, once per load. */
83
- export function stampSession(html, stamp) {
84
- const meta = `<meta name="carmar-session" content="${stamp}">`;
94
+ /**
95
+ * Stamp the served HTML with this session's identity, once per load — and, for a product other
96
+ * than Beatrina (`app`, BEATRIX_APP_ID: "beatrina"), with which product served it, so the page's
97
+ * Start R can name the door this product has (docs/launch-menu-plan.md L6). A Beatrina page is the
98
+ * installed file plus the ONE session meta, exactly as before (test/host-launch.test.mjs pins that
99
+ * byte for byte); a page opened from disk carries neither, and its Start R stays Beatrina's.
100
+ */
101
+ export function stampSession(html, stamp, app = "") {
102
+ const product = String(app || "").replace(/[^A-Za-z0-9.]/g, "");
103
+ const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}`;
85
104
  let m = html.match(/<head\b[^>]*>/);
86
105
  if (!m) m = html.match(/<html\b[^>]*>/);
87
106
  if (!m) return meta + html;
@@ -91,7 +110,7 @@ export function stampSession(html, stamp) {
91
110
 
92
111
  /**
93
112
  * @param {Object} opts
94
- * @param {ReturnType<import("./deployment.mjs").carmarDeployment>} opts.deployment
113
+ * @param {ReturnType<import("./deployment.mjs").beatrinaDeployment>} opts.deployment
95
114
  * @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
96
115
  * router; it presents one WorkerPlane's surface, so everything here reads as
97
116
  * it did when there was one engine (WP4, docs/wp/wp4-python.md)
@@ -104,16 +123,38 @@ export function stampSession(html, stamp) {
104
123
  * @param {(event: string, fields?: object) => void} opts.audit
105
124
  * @param {() => void} opts.onShutdown
106
125
  * @param {string} opts.here the directory holding the fallback spike page
126
+ * @param {string} [opts.gatewayToken] private gateway credential, required on HTTP and WS
107
127
  */
108
128
  export function createHostServer(opts) {
109
129
  const { deployment, plane, audit, onShutdown, planes = [] } = opts;
130
+ const gatewayOk = gatewayTokenGate(opts.gatewayToken);
131
+ const gatewayMode = opts.gatewayToken !== undefined;
132
+ const commands = gatewayCommands(planes.flatMap((pl) => pl.commands || []), gatewayMode);
133
+ const capabilities = planes.flatMap((pl) => pl.capabilities || []).filter((capability) => !gatewayMode || !GATEWAY_HIDDEN_CAPABILITIES.includes(capability));
134
+ const managedError = "Sessions and software updates are managed by the Beatrina server. Use the server workspace controls.";
110
135
  const originsOk = deployment.origins;
111
136
  const hostsOk = deployment.hosts;
112
137
  const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
113
138
  const approvals = new Set(); // exact origins the user approved this session
114
- const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false };
139
+ const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false,
140
+ // The documents open in this session, as the pages last reported them (host/session-keep.mjs).
141
+ sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
115
142
  plane.sockets = () => sockets;
116
- plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
143
+ // A TERMINAL worker death leaves the page stranded: no R, but a supervisor that still answers the
144
+ // handoff (src/r-kernel.js `stranded`). A page that connects AFTER the death — the page's own
145
+ // reconnect does exactly that — never saw a ready frame to learn this session's build or its
146
+ // doors from, so the notice carries them. Our own frame, re-encoded; never a worker's bytes.
147
+ const strandedNotice = (payload) => {
148
+ if (typeof payload !== "string" || !payload.includes('"worker-died"') || !payload.includes('"terminal":true')) return payload;
149
+ try {
150
+ const frame = JSON.parse(payload);
151
+ const doors = commands.filter((c) => c === "session_upgrade" || c === "session_restart");
152
+ return enc({ ...frame, session: { kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(), commands: doors } });
153
+ } catch { return payload; }
154
+ };
155
+ plane.on("broadcast", (payload) => { const out = strandedNotice(payload); sockets.forEach((r) => r.ws.send(out)); });
156
+ // A restore's report is about every object in the session: pages only, never an agent or a native client.
157
+ plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
117
158
 
118
159
  // The session stamp: ONE string, rendered once, that the page's meta and
119
160
  // /health's `started` share so they can never differ in their digits.
@@ -187,6 +228,7 @@ export function createHostServer(opts) {
187
228
  const ctx = {
188
229
  deployment, plane, audit, sockets, approvals, state, opts,
189
230
  enc, scalarChr, pageRecs, socketClass,
231
+ sessionDocuments: () => state.sessionDocuments,
190
232
  port: () => (server.address() ? server.address().port : deployment.port),
191
233
  respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
192
234
  reject: (res, reason, detail) => reject(res, reason, detail),
@@ -212,8 +254,13 @@ export function createHostServer(opts) {
212
254
  }
213
255
 
214
256
  const server = http.createServer((req, res) => {
257
+ if (!gatewayOk(req)) return reject(res, "gateway authentication required");
215
258
  if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
216
259
  const pathname = new URL(req.url, "http://x").pathname;
260
+ if (gatewayMode && GATEWAY_MANAGED_PATHS.includes(pathname)) {
261
+ audit("server-managed-refused", { path: pathname });
262
+ return respond(res, 403, "application/json", enc({ ok: false, reason: "server-managed", error: managedError }));
263
+ }
217
264
  if (pathname === "/health") {
218
265
  if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
219
266
  const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
@@ -229,14 +276,14 @@ export function createHostServer(opts) {
229
276
  // Which build this kernel replaced, for the first 90 s of its life —
230
277
  // how a page recognises its successor. Never the page or its capability.
231
278
  ...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
232
- capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...planes.flatMap((pl) => pl.capabilities || [])],
279
+ capabilities: [...(gatewayMode ? [] : ["published-direct-v1", "published-pairing-v3", "session-restart-v1"]), ...capabilities],
233
280
  // `engines` is what a chunk may be RUN on, so a file page probing for
234
281
  // a kernel can tell a two-engine host from a one-engine one before it
235
282
  // opens a socket. It names every engine a runtime was found for, not
236
283
  // only the ones that have started (WP4; docs/wp/wp4-python.md).
237
284
  host: "beatrix",
238
285
  // Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
239
- app: opts.app || "carmar",
286
+ app: opts.app || "beatrina",
240
287
  engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
241
288
  engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
242
289
  }), { "Access-Control-Allow-Origin": "*" });
@@ -257,13 +304,17 @@ export function createHostServer(opts) {
257
304
  const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
258
305
  let html;
259
306
  try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
260
- return respond(res, 200, "text/html", stampSession(html, sessionStamp));
307
+ return respond(res, 200, "text/html", stampSession(html, sessionStamp, opts.app || "beatrina"));
261
308
  }
262
309
  return respond(res, 404, "text/plain", "not found");
263
310
  });
264
311
 
265
312
  attachWebSocket(server, {
266
313
  gate: (req) => {
314
+ if (!gatewayOk(req)) {
315
+ audit("rejected", { reason: "gateway authentication required" });
316
+ return { status: 403, body: "forbidden: gateway authentication required" };
317
+ }
267
318
  if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
268
319
  const origin = req.headers.origin;
269
320
  const approved = origin != null && approvals.has(origin);
@@ -277,14 +328,22 @@ export function createHostServer(opts) {
277
328
  const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
278
329
  user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
279
330
  sockets.push(rec);
331
+ // A pong is the transport answering: the browser's network process sends it even while the
332
+ // page's thread is busy rendering or throttled behind another window, so it counts as life.
333
+ ws.onPong(() => { rec.lastSeen = Date.now(); });
280
334
  state.everConnected = true;
281
335
  audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
282
336
  // The engine announced itself long before this page connected; replay
283
337
  // it — and every other engine that has since started, each under its own
284
338
  // `engine-ready` (host/engine-pool.mjs replay()).
285
339
  if (plane.hello) ws.send(plane.hello);
286
- if (plane.notice) ws.send(plane.notice);
340
+ if (plane.notice) ws.send(strandedNotice(plane.notice));
287
341
  if (typeof plane.replay === "function") plane.replay(ws);
342
+ // …and a restore that finished before this page arrived (a page following a handoff).
343
+ if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
344
+ const report = plane.freshResumeReport();
345
+ if (report) ws.send(report);
346
+ }
288
347
  for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
289
348
  ws.onMessage((message) => {
290
349
  try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
@@ -321,6 +380,11 @@ export function createHostServer(opts) {
321
380
  if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
322
381
  rec.lastSeen = Date.now();
323
382
  const type = cmd.type;
383
+ if (gatewayMode && GATEWAY_MANAGED_COMMANDS.includes(type)) {
384
+ audit("server-managed-refused", { type });
385
+ rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), ok: false, reason: "server-managed", error: managedError }));
386
+ return undefined;
387
+ }
324
388
 
325
389
  if (type === "hb") {
326
390
  rec.beats = true;
@@ -346,6 +410,24 @@ export function createHostServer(opts) {
346
410
  rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
347
411
  return undefined;
348
412
  }
413
+ // The documents this session has open. Page-only in BOTH senses: the list is file paths, and a page
414
+ // that could not open a terminal must not be able to rewrite what the session reopens either.
415
+ if (type === "page-documents") {
416
+ if (rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class)) {
417
+ state.sessionDocuments = cleanSessionDocuments(cmd.documents);
418
+ opts.onSessionDocuments?.(state.sessionDocuments);
419
+ }
420
+ return undefined;
421
+ }
422
+ // A page asks what this session had open, to reopen it. `others` counts the OTHER pages attached: a
423
+ // page joining a session another window shows must not open the same documents a second time.
424
+ if (type === "session-documents") {
425
+ if (!scalarChr(cmd.id)) return undefined;
426
+ const allowed = rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class);
427
+ const others = pageRecs().filter((r) => r !== rec).length;
428
+ rec.ws.send(enc({ type, id: cmd.id, documents: allowed ? state.sessionDocuments : [], others, refused: !allowed }));
429
+ return undefined;
430
+ }
349
431
  if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
350
432
  audit("mcp-refused", { reason: `agent asked for ${type}` });
351
433
  // serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
@@ -363,14 +445,22 @@ export function createHostServer(opts) {
363
445
  audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
364
446
  return undefined;
365
447
  }
448
+ // "Restart, keep variables": the save is a copy of the whole session, so it is page-only in BOTH
449
+ // senses, and its cancel interrupts only a save THIS page asked for.
450
+ if ((type === "suspend" || type === "suspend_cancel") && !PAGE_ONLY_CLASSES.includes(rec.class)) {
451
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: "Only the local notebook page may keep the workspace." }));
452
+ audit("suspend-refused", { reason: "class", class: rec.class || "unknown" });
453
+ return undefined;
454
+ }
366
455
  switch (type) {
456
+ case "suspend_cancel": if (typeof plane.cancelSuspend === "function") plane.cancelSuspend(rec); return undefined;
367
457
  case "exec": return plane.exec(cmd, rec);
368
458
  case "interrupt": return plane.interrupt(cmd, rec);
369
459
  // Both carry an OPTIONAL engine. `restart` with none is the primary
370
460
  // engine's — which is what "Restart R" in the page means and what every
371
461
  // client built before there was a second engine sends (§6.5).
372
462
  case "force_stop": return plane.forceStop(rec, cmd);
373
- case "restart": Promise.resolve(plane.restart(cmd.engine)).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
463
+ case "restart": Promise.resolve(plane.restart(cmd.engine, { resume: cmd.resume })).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
374
464
  case "runstate": return plane.runstate(cmd, rec);
375
465
  case "runs": return plane.runs(cmd, rec);
376
466
  case "adopt": return plane.adopt(cmd, rec);
@@ -410,13 +500,25 @@ export function createHostServer(opts) {
410
500
  return undefined;
411
501
  }
412
502
 
413
- /** Close and forget sockets that have stopped answering (only those that beat). */
414
- const silenceMs = Math.max(0, Number(process.env.CARMAR_SOCKET_SILENCE ?? 90)) * 1000;
503
+ /** Close and forget sockets that have stopped answering (only those that beat).
504
+ *
505
+ * Silence alone used to be the verdict, and a page whose OWN thread was busy — rendering a
506
+ * large result, or throttled by the browser behind another window during a long chunk — sent
507
+ * no `hb` for 90 s and was cut off while R worked; the page then printed "Connection to the R
508
+ * kernel closed", redialled and adopted the run back. Misleading, and the owner said so
509
+ * (2026-09-17). So a page silent for half the threshold is PINGED at the protocol level: the
510
+ * browser answers a ping from its network process whatever the page's thread is doing, the
511
+ * pong refreshes `lastSeen` (onPong above), and only a socket that answers nothing — a
512
+ * sleeping laptop, a dropped link, a killed browser — reaches the threshold and is closed. */
513
+ const silenceMs = Math.max(0, Number(readEnv(process.env, "SOCKET_SILENCE", "90"))) * 1000;
415
514
  const reaper = setInterval(() => {
416
515
  if (!silenceMs || !sockets.length) return;
417
516
  const now = Date.now();
418
517
  for (const rec of [...sockets]) {
419
- if (rec.beats && now - rec.lastSeen >= silenceMs) {
518
+ if (!rec.beats) continue;
519
+ const silent = now - rec.lastSeen;
520
+ if (silent >= silenceMs / 2 && silent < silenceMs) { rec.ws.ping(); continue; }
521
+ if (silent >= silenceMs) {
420
522
  audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
421
523
  const at = sockets.indexOf(rec);
422
524
  if (at >= 0) sockets.splice(at, 1);
@@ -434,8 +536,8 @@ export function createHostServer(opts) {
434
536
  sessionStamp,
435
537
  ctx,
436
538
  /** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
437
- commands: planes.flatMap((pl) => pl.commands || []),
438
- capabilities: planes.flatMap((pl) => pl.capabilities || []),
539
+ commands,
540
+ capabilities,
439
541
  close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
440
542
  };
441
543
  }
@@ -0,0 +1,70 @@
1
+ // session-keep.mjs — the pure rules behind two CarmaR 0.8.5 features, as the host answers them.
2
+ //
3
+ // · "Restart, keep variables": the worker saves the session (`suspend`, spike/workspace-keep.R) and
4
+ // answers a TOKEN; the host never reads a kept workspace, it only checks that a token is one the
5
+ // worker could have minted and carries it to the next worker (`resume`). A path is never a token.
6
+ // · "A session keeps its documents": the page reports what it has open (`page-documents`), the host
7
+ // keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
8
+ //
9
+ // Transcribed rule for rule from spike/workspace-keep.R (keep_token_ok) and spike/session-documents.R
10
+ // (session_documents_clean/label/encode/decode), which Beatrina's R supervisor sources; pinned against
11
+ // the same cases by test/session-keep.test.mjs.
12
+
13
+ import path from "node:path";
14
+
15
+ export const KEEP_TOKEN_RE = /^[0-9]{14}-[0-9a-f]{16}$/;
16
+ export const SESSION_DOCUMENTS_MAX = 64;
17
+ export const SESSION_DOCUMENT_FORMATS = Object.freeze(["qmd", "Rmd", "md", "carmd"]);
18
+ /** How long a restore's report is replayed to a page that connects late (serve.R: 120 s). */
19
+ export const RESUME_REPORT_MS = 120000;
20
+
21
+ /** Is `x` a token the worker could have minted? */
22
+ export const keepTokenOk = (x) => typeof x === "string" && KEEP_TOKEN_RE.test(x);
23
+
24
+ // eslint-disable-next-line no-control-regex
25
+ const CONTROL = /[\x00-\x1f\x7f]/g;
26
+ const textOf = (value, cap) => {
27
+ if (typeof value !== "string") return null;
28
+ const v = value.replace(CONTROL, " ");
29
+ return v.length > cap ? null : v;
30
+ };
31
+
32
+ /** The documents a page reported, cleaned: absolute paths, bounded names, known formats, no duplicates. */
33
+ export function cleanSessionDocuments(documents) {
34
+ if (!Array.isArray(documents) || !documents.length) return [];
35
+ const rows = [];
36
+ const seen = new Set();
37
+ for (const row of documents) {
38
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
39
+ const p = textOf(row.path ?? "", 4096);
40
+ const name = textOf(row.name ?? "", 200);
41
+ let format = textOf(row.format ?? "", 16);
42
+ if (p == null || name == null || format == null) continue;
43
+ // Reopened through the file ops, which resolve it themselves; here it need only be absolute.
44
+ if (p && !/^(\/|[A-Za-z]:[/\\])/.test(p)) continue;
45
+ if (!p && !name.trim()) continue;
46
+ if (!SESSION_DOCUMENT_FORMATS.includes(format)) format = "qmd";
47
+ if (p) { if (seen.has(p)) continue; seen.add(p); }
48
+ rows.push({ path: p, name: name.trim(), format, active: row.active === true });
49
+ if (rows.length >= SESSION_DOCUMENTS_MAX) break;
50
+ }
51
+ return rows;
52
+ }
53
+
54
+ /** "SezerProfiles.qmd + 1 more": what the menu helper shows for a session. */
55
+ export function sessionDocumentsLabel(documents) {
56
+ if (!Array.isArray(documents) || !documents.length) return "";
57
+ const lead = documents.find((d) => d.active === true) || documents[0];
58
+ let name = lead.path ? path.basename(lead.path.replace(/\\/g, "/")) : lead.name;
59
+ if (!lead.path) name = `${name} (unsaved)`;
60
+ const rest = documents.length - 1;
61
+ const label = rest > 0 ? `${name} + ${rest} more` : name;
62
+ return label.replace(CONTROL, " ").replace(/["\\]/g, "'").slice(0, 120).trim();
63
+ }
64
+
65
+ export const encodeSessionDocuments = (documents) => (Array.isArray(documents) && documents.length ? JSON.stringify(documents) : "");
66
+
67
+ export function decodeSessionDocuments(text) {
68
+ if (typeof text !== "string" || !text) return [];
69
+ try { return cleanSessionDocuments(JSON.parse(text)); } catch { return []; }
70
+ }
package/host/settings.mjs CHANGED
@@ -13,10 +13,10 @@
13
13
  // · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
14
14
  // rejected on read and refused on write, always — never "env wins if
15
15
  // present", because that only guarantees anything when the env IS set, and
16
- // a deployment that never set CARMAR_ROOT is still one where a user must
16
+ // a deployment that never set BEATRINA_ROOT is still one where a user must
17
17
  // not invent one.
18
18
  //
19
- // The tempting alternative — one layered getter handed to carmarDeployment()
19
+ // The tempting alternative — one layered getter handed to beatrinaDeployment()
20
20
  // and the AI policy — is deliberately NOT built here either. It would put every
21
21
  // administrator variable one argument-passing mistake away from being
22
22
  // file-settable. Those read process.env directly; only the user-writable call
@@ -32,6 +32,7 @@ import fs from "node:fs";
32
32
 
33
33
  import path from "node:path";
34
34
  import { appId, ensureUserDir, privateModeHeld } from "./user-dirs.mjs";
35
+ import { envReader, mirrorEnv, setEnvName } from "./env-names.mjs";
35
36
 
36
37
  export const SETTINGS_VERSION = 1;
37
38
 
@@ -46,12 +47,19 @@ export const SETTINGS_VERSION = 1;
46
47
  export const SETTINGS = Object.freeze([
47
48
  // Connection loss is not permission to discard the R workspace. Timed
48
49
  // shutdown is opt-in; 0 retains an attached session until an explicit Quit.
49
- { key: "linger_seconds", env: "CARMAR_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
50
- { key: "history_enabled", env: "CARMAR_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
51
- { key: "analyze_budget_s", env: "CARMAR_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
52
- { key: "quarto_path", env: "CARMAR_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
53
- { key: "rscript_path", env: "CARMAR_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
54
- { key: "port", env: "CARMAR_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
50
+ { key: "linger_seconds", env: "BEATRINA_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
51
+ { key: "history_enabled", env: "BEATRINA_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
52
+ { key: "analyze_budget_s", env: "BEATRINA_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
53
+ { key: "quarto_path", env: "BEATRINA_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
54
+ { key: "rscript_path", env: "BEATRINA_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
55
+ { key: "port", env: "BEATRINA_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
56
+ // WHO IS WRITING. Machine scope, beside console history and the AI store,
57
+ // and deliberately NOT localStorage: every file:// page shares one origin,
58
+ // so a name kept there would be readable by any local HTML the user opens
59
+ // and would not follow them to a served page either. With no kernel there
60
+ // is no name, which is consistent — with no kernel there is no comment
61
+ // store to attribute anything in.
62
+ { key: "person_name", env: "BEATRINA_NAME", kind: "text", max: 120, default: "", effect: "live", label: "Your name" },
55
63
  ]);
56
64
 
57
65
  /**
@@ -60,37 +68,63 @@ export const SETTINGS = Object.freeze([
60
68
  * the refusal structural rather than a lookup someone can forget.
61
69
  */
62
70
  export const SETTINGS_ADMIN = Object.freeze([
63
- "CARMAR_MANAGED_CONFIG",
64
- "CARMAR_ROOT", "CARMAR_LOG", "CARMAR_LOG_AI_TEXT",
65
- "CARMAR_CRAN_MIRROR",
66
- "CARMAR_AI_LOCAL_ONLY", "CARMAR_AI_POLICY", "CARMAR_AI_PROVIDERS",
67
- "CARMAR_BIND", "CARMAR_HOSTS", "CARMAR_ORIGINS", "CARMAR_REQUIRE_ORIGIN",
68
- "CARMAR_TRUST_PROXY", "CARMAR_USER_HEADER", "CARMAR_TRUSTED_PROXY",
69
- "CARMAR_ALLOW_UNAUTHENTICATED", "CARMAR_PORT_STRICT",
70
- "CARMAR_UPDATE_FEED", "CARMAR_UPDATE_MIRROR", "CARMAR_UPDATE_OFFLINE_DIR",
71
- "CARMAR_UPDATE_MANAGED",
71
+ "BEATRINA_MANAGED_CONFIG",
72
+ "BEATRINA_ROOT", "BEATRINA_LOG", "BEATRINA_LOG_AI_TEXT",
73
+ "BEATRINA_CRAN_MIRROR",
74
+ "BEATRINA_AI_LOCAL_ONLY", "BEATRINA_AI_POLICY", "BEATRINA_AI_PROVIDERS",
75
+ "BEATRINA_BIND", "BEATRINA_HOSTS", "BEATRINA_ORIGINS", "BEATRINA_REQUIRE_ORIGIN",
76
+ "BEATRINA_TRUST_PROXY", "BEATRINA_USER_HEADER", "BEATRINA_TRUSTED_PROXY",
77
+ "BEATRINA_ALLOW_UNAUTHENTICATED", "BEATRINA_PORT_STRICT",
78
+ "BEATRINA_UPDATE_FEED", "BEATRINA_UPDATE_MIRROR", "BEATRINA_UPDATE_OFFLINE_DIR",
79
+ "BEATRINA_UPDATE_MANAGED",
72
80
  ]);
73
81
 
74
- export const MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
82
+ export const MANAGED_SETTINGS_SCHEMA = "beatrina-managed-settings-v1";
75
83
  export const MANAGED_SETTINGS_ENV = Object.freeze([...new Set([
76
84
  ...SETTINGS.map((s) => s.env),
77
- ...SETTINGS_ADMIN.filter((n) => n !== "CARMAR_MANAGED_CONFIG"),
85
+ ...SETTINGS_ADMIN.filter((n) => n !== "BEATRINA_MANAGED_CONFIG"),
78
86
  "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
79
87
  ])]);
80
88
 
89
+ // An administrator's file written for Beatrina 0.8.x: its schema string and
90
+ // its keys spell the old product name. Both are read; `managedRead` reports
91
+ // the values under the current names, and nothing writes the old ones.
92
+ // beatrina-names: keep-start
93
+ const LEGACY_MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
94
+ const LEGACY_MANAGED_KEY = /^CARMAR_/;
95
+ // beatrina-names: keep-end
96
+ // A key is reported as itself when the vocabulary knows it, else under its current spelling.
97
+ const currentKey = (k) => (MANAGED_SETTINGS_ENV.includes(k) ? k : k.replace(LEGACY_MANAGED_KEY, "BEATRINA_"));
98
+
81
99
  export const settingsEntry = (key) => SETTINGS.find((s) => s.key === key) || null;
82
100
 
83
- const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
101
+ // Dual-read (host/env-names.mjs): every `env:` name in SETTINGS is answered
102
+ // under its older spelling too; the page (lib/settings/kernel-pane.js) keys its
103
+ // admin rows on the same names, in the same spelling.
104
+ const sysEnv = envReader(process.env);
84
105
 
85
106
  // ── managed configuration ───────────────────────────────────────────────────
86
107
 
87
- export function managedDefaultPath(env = sysEnv, platform = process.platform) {
88
- if (platform === "darwin") return "/Library/Application Support/CarmaR/managed-settings.json";
108
+ /** The places an administrator's file is looked for on this platform, the current one first. */
109
+ export function managedDefaultPaths(env = sysEnv, platform = process.platform) {
110
+ if (platform === "darwin") {
111
+ return ["/Library/Application Support/Beatrina/managed-settings.json",
112
+ "/Library/Application Support/CarmaR/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
113
+ }
89
114
  if (platform === "win32") {
90
115
  const base = String(env("ProgramData", "")).trim() || "C:/ProgramData";
91
- return path.join(base, "CarmaR", "managed-settings.json");
116
+ return [path.join(base, "Beatrina", "managed-settings.json"),
117
+ path.join(base, "CarmaR", "managed-settings.json")]; // beatrina-names: keep — a file deployed for 0.8.x
92
118
  }
93
- return "/etc/carmar/managed-settings.json";
119
+ return ["/etc/beatrina/managed-settings.json",
120
+ "/etc/carmar/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
121
+ }
122
+
123
+ /** The administrator's file: the current path, unless only the older one exists. */
124
+ export function managedDefaultPath(env = sysEnv, platform = process.platform) {
125
+ const [current, ...older] = managedDefaultPaths(env, platform);
126
+ if (fs.existsSync(current)) return current;
127
+ return older.find((p) => p !== current && fs.existsSync(p)) || current;
94
128
  }
95
129
 
96
130
  /**
@@ -120,12 +154,12 @@ export function managedProtected(file, platform = process.platform) {
120
154
  export function managedRead(file, { required = false, protection = managedProtected } = {}) {
121
155
  const empty = { status: "missing", path: file, values: {}, errors: [] };
122
156
  if (!fs.existsSync(file)) {
123
- if (required) return { ...empty, status: "invalid", errors: ["CARMAR_MANAGED_CONFIG names a file that does not exist."] };
157
+ if (required) return { ...empty, status: "invalid", errors: ["BEATRINA_MANAGED_CONFIG names a file that does not exist."] };
124
158
  return empty;
125
159
  }
126
160
  if (protection(file) !== true) {
127
161
  return { ...empty, status: "insecure",
128
- errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; CarmaR refused it."] };
162
+ errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; Beatrina refused it."] };
129
163
  }
130
164
  let size = NaN;
131
165
  try { size = fs.statSync(file).size; } catch { size = NaN; }
@@ -138,12 +172,12 @@ export function managedRead(file, { required = false, protection = managedProtec
138
172
  const rootNames = plain ? Object.keys(raw) : null;
139
173
  const settingsPlain = plain && raw.settings && typeof raw.settings === "object" && !Array.isArray(raw.settings);
140
174
  if (!rootNames || rootNames.length !== 2 || !rootNames.includes("schema") || !rootNames.includes("settings")
141
- || raw.schema !== MANAGED_SETTINGS_SCHEMA || !settingsPlain) {
175
+ || ![MANAGED_SETTINGS_SCHEMA, LEGACY_MANAGED_SETTINGS_SCHEMA].includes(raw.schema) || !settingsPlain) {
142
176
  return { ...empty, status: "invalid",
143
177
  errors: [`The managed settings file must use schema ${MANAGED_SETTINGS_SCHEMA} with one settings object.`] };
144
178
  }
145
179
  const keys = Object.keys(raw.settings);
146
- const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(k));
180
+ const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(currentKey(k)));
147
181
  const bad = keys.filter((k) => {
148
182
  const v = raw.settings[k];
149
183
  return !(typeof v === "string" && v.length > 0 && !/[\r\n\t]/.test(v));
@@ -153,7 +187,7 @@ export function managedRead(file, { required = false, protection = managedProtec
153
187
  if (bad.length) errors.push(`Managed settings must be non-empty single-line strings: ${bad.join(", ")}.`);
154
188
  if (errors.length) return { ...empty, status: "invalid", errors };
155
189
  const values = {};
156
- for (const k of keys) values[k] = raw.settings[k];
190
+ for (const k of keys) values[currentKey(k)] = raw.settings[k];
157
191
  return { status: "ok", path: file, values, errors: [] };
158
192
  }
159
193
 
@@ -162,11 +196,13 @@ export function managedRead(file, { required = false, protection = managedProtec
162
196
  * every subsystem that follows resolves under the ordinary env-wins rule.
163
197
  */
164
198
  export function managedEnvironment({ env = sysEnv, file = null, protection = managedProtected, apply = true } = {}) {
165
- const explicit = String(env("CARMAR_MANAGED_CONFIG", "")).trim();
199
+ const explicit = String(env("BEATRINA_MANAGED_CONFIG", "")).trim();
166
200
  const target = file || (explicit || managedDefaultPath(env));
167
201
  const result = managedRead(target, { required: Boolean(explicit), protection });
168
202
  if (apply && result.status === "ok") {
169
- for (const [k, v] of Object.entries(result.values)) process.env[k] = v;
203
+ // Under BOTH spellings: a managed value must be what every reader sees,
204
+ // whichever prefix it asks for.
205
+ for (const [k, v] of Object.entries(result.values)) setEnvName(process.env, k, v);
170
206
  }
171
207
  return result;
172
208
  }
@@ -215,6 +251,18 @@ export function validateSetting(key, value) {
215
251
  if (entry.max != null && value > entry.max) return { ok: false, reason: `must be at most ${entry.max}` };
216
252
  return { ok: true, value: Number(value) };
217
253
  }
254
+ if (entry.kind === "text") {
255
+ if (typeof value !== "string") return { ok: false, reason: "must be a string" };
256
+ const text = value.trim();
257
+ const cap = entry.max == null ? 200 : entry.max;
258
+ if (text.length > cap) return { ok: false, reason: `must be at most ${cap} characters` };
259
+ // A control character is not a typo here: this value is written into
260
+ // JSON-lines logs (a comment's author), and a newline in one of those
261
+ // forges a record. `ai-audit` strips them for the same reason; a setting
262
+ // can refuse instead, because a person typed it and can be told.
263
+ if (/[\u0000-\u001f\u007f]/.test(text)) return { ok: false, reason: "must not contain control characters" };
264
+ return { ok: true, value: text };
265
+ }
218
266
  // path
219
267
  if (typeof value !== "string") return { ok: false, reason: "must be a string" };
220
268
  // An empty path means "use the ladder" — a legitimate value, not a missing
@@ -247,7 +295,7 @@ export function readSettingsFile(file = settingsPath()) {
247
295
  return { ...empty, status: "invalid", error: "The settings file could not be read as JSON." };
248
296
  }
249
297
  if (Math.trunc(Number(raw.version || 0)) !== SETTINGS_VERSION) {
250
- return { ...empty, status: "invalid", error: "The settings file is a version this CarmaR does not know." };
298
+ return { ...empty, status: "invalid", error: "The settings file is a version this Beatrina does not know." };
251
299
  }
252
300
  const block = raw.settings == null ? {} : raw.settings;
253
301
  if (typeof block !== "object" || Array.isArray(block)) {
@@ -298,7 +346,7 @@ export function resolveSettings({ env = sysEnv, file = null } = {}) {
298
346
  if (fromEnv) {
299
347
  source = "env";
300
348
  if (s.kind === "bool") {
301
- // CARMAR_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
349
+ // BEATRINA_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
302
350
  // the file and the UI hold the positive. Translating happens here, once.
303
351
  value = s.inverted ? false : true;
304
352
  } else if (s.kind === "number") {
@@ -330,10 +378,10 @@ export const settingsValue = (resolved, key) => (resolved.settings[key] ? resolv
330
378
  export function settingsChildEnv(resolved) {
331
379
  const out = {};
332
380
  const q = settingsValue(resolved, "quarto_path");
333
- if (typeof q === "string" && q.length) out.CARMAR_QUARTO = q;
381
+ if (typeof q === "string" && q.length) out.BEATRINA_QUARTO = q;
334
382
  const b = settingsValue(resolved, "analyze_budget_s");
335
- if (typeof b === "number") out.CARMAR_ANALYZE_BUDGET = String(b);
336
- return out;
383
+ if (typeof b === "number") out.BEATRINA_ANALYZE_BUDGET = String(b);
384
+ return mirrorEnv(out); // a child that still reads BEATRINA_QUARTO sees it
337
385
  }
338
386
 
339
387
  /**
@@ -359,7 +407,7 @@ export function createSettings({ env = sysEnv, applyManaged = true } = {}) {
359
407
  /** Which R the worker, the analyzer and every job child should run. */
360
408
  rscriptPreference() {
361
409
  const chosen = api.value("rscript_path");
362
- return typeof chosen === "string" && chosen.length ? chosen : String(env("CARMAR_RSCRIPT", ""));
410
+ return typeof chosen === "string" && chosen.length ? chosen : String(env("BEATRINA_RSCRIPT", ""));
363
411
  },
364
412
  /**
365
413
  * The idle clock. The same safe default applies locally and behind a proxy:
@@ -8,12 +8,13 @@
8
8
  // puts `update_available` on /health and on the ready frame only when the recorded version is newer
9
9
  // than the build that is running, and the page says it (lib/info-pane.js, lib/doctor.js).
10
10
  //
11
- // CARMAR_NO_UPDATE_CHECK=1 or BEATRINA_NO_UPDATE_CHECK=1 turns the reading off as well as the asking
11
+ // BEATRINA_NO_UPDATE_CHECK=1 (or its older spelling) turns the reading off as well as the asking
12
12
  // (the battery runner sets the first), so no test kernel reports a version some real run recorded.
13
13
 
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { rUserDir } from "./user-dirs.mjs";
17
+ import { readEnv } from "./env-names.mjs";
17
18
 
18
19
  /** -1, 0, 1 for semver-shaped versions; a pre-release sorts before its release; nonsense compares equal. */
19
20
  export function compareVersions(a, b) {
@@ -38,7 +39,7 @@ export function updateRecordFile(app) {
38
39
 
39
40
  /** The check is off: nothing is asked and nothing is read. */
40
41
  export function updateCheckOff(env = process.env) {
41
- return env.BEATRINA_NO_UPDATE_CHECK === "1" || env.CARMAR_NO_UPDATE_CHECK === "1";
42
+ return readEnv(env, "NO_UPDATE_CHECK") === "1";
42
43
  }
43
44
 
44
45
  /**