beatrina 0.8.7 → 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 (111) 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 +152 -24
  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 +250 -42
  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 +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
@@ -52,12 +52,13 @@ import crypto from "node:crypto";
52
52
  import fs from "node:fs";
53
53
  import path from "node:path";
54
54
  import { privateModeHeld, stateDir } from "./user-dirs.mjs";
55
+ import { envReader } from "./env-names.mjs";
55
56
 
56
57
  const TAB = 0x09;
57
58
  const NL = 0x0a;
58
59
  export const JOURNAL_KEY_RE = /^[a-z]+:[A-Za-z0-9._~-]{1,200}$/;
59
60
  export const JOURNAL_MAX_REPLY_BYTES = 6e6; // under the 8 MB frame cap, with room
60
- const envOf = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
61
+ const envOf = envReader(process.env); // dual-read (host/env-names.mjs)
61
62
 
62
63
  /** A UTC stamp in the journal's evidence-filename shape: %Y%m%d-%H%M%S. */
63
64
  const stamp = () => {
@@ -73,11 +74,11 @@ const bytesOf = (text) => Buffer.byteLength(text, "utf8");
73
74
  * first's idea of where a tail is.
74
75
  */
75
76
  export function createJournalStore() {
76
- const enabled = envOf("CARMAR_NO_JOURNAL") !== "1";
77
+ const enabled = envOf("BEATRINA_NO_JOURNAL") !== "1";
77
78
  const cache = new Map();
78
79
 
79
80
  const root = () => {
80
- const explicit = envOf("CARMAR_HISTORY_DIR");
81
+ const explicit = envOf("BEATRINA_HISTORY_DIR");
81
82
  if (explicit) return explicit;
82
83
  return path.join(stateDir(), "history");
83
84
  };
@@ -87,7 +88,7 @@ export function createJournalStore() {
87
88
  * stacks; pins hold their own content), never the tail.
88
89
  */
89
90
  const budget = () => {
90
- const v = Number(envOf("CARMAR_HISTORY_BUDGET"));
91
+ const v = Number(envOf("BEATRINA_HISTORY_BUDGET"));
91
92
  return !Number.isFinite(v) || v < 65536 ? 64 * 1024 * 1024 : v;
92
93
  };
93
94
  const validKey = (key) => typeof key === "string" && JOURNAL_KEY_RE.test(key);
@@ -174,7 +175,7 @@ export function createJournalStore() {
174
175
 
175
176
  /** Atomic replace within the same directory: write a sibling, rename over. */
176
177
  const replace = (file, text) => {
177
- const temp = path.join(path.dirname(file), `.carmar-journal-${crypto.randomBytes(8).toString("hex")}`);
178
+ const temp = path.join(path.dirname(file), `.beatrina-journal-${crypto.randomBytes(8).toString("hex")}`);
178
179
  writePrivate(temp, text);
179
180
  try { fs.renameSync(temp, file); } catch (e) {
180
181
  try { fs.unlinkSync(temp); } catch { /* gone */ }
@@ -263,7 +264,7 @@ export function createJournalStore() {
263
264
  return { held: { pid: Math.trunc(prior.pid), port: prior.port, epoch: prior.epoch }, epoch: null };
264
265
  }
265
266
  const epoch = prior && typeof prior.epoch === "number" ? Math.trunc(prior.epoch) + 1 : 1;
266
- const me = { pid: mePid, port: Number.parseInt(envOf("CARMAR_PORT", "0"), 10) || 0, epoch, at: Date.now() };
267
+ const me = { pid: mePid, port: Number.parseInt(envOf("BEATRINA_PORT", "0"), 10) || 0, epoch, at: Date.now() };
267
268
  try { replace(file, JSON.stringify(me)); } catch {
268
269
  return { held: null, epoch: null, error: "The writer lease could not be written." };
269
270
  }
@@ -295,7 +296,7 @@ export function createJournalStore() {
295
296
  // ── the ops ───────────────────────────────────────────────────────────────
296
297
 
297
298
  const open = (id, key, meta, takeover) => {
298
- if (!enabled) return err("journal_open", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1).");
299
+ if (!enabled) return err("journal_open", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1).");
299
300
  if (!validKey(key)) return err("journal_open", id, "invalid_key", "A journal needs a durable document key.");
300
301
  let st;
301
302
  try { st = stateOf(key); } catch (e) { return err("journal_open", id, "io", e.message); }
@@ -323,7 +324,7 @@ export function createJournalStore() {
323
324
  * directory behind, so it never calls stateOf().
324
325
  */
325
326
  const probe = (id, key) => {
326
- if (!enabled) return err("journal_probe", id, "unavailable", "The document journal is disabled (CARMAR_NO_JOURNAL=1).");
327
+ if (!enabled) return err("journal_probe", id, "unavailable", "The document journal is disabled (BEATRINA_NO_JOURNAL=1).");
327
328
  if (!validKey(key)) return err("journal_probe", id, "invalid_key", "A journal needs a durable document key.");
328
329
  const dir = dirOf(key);
329
330
  if (!fs.existsSync(dir)) return { type: "journal_probe", id, key, exists: false, path: null, pathExists: false };
@@ -674,7 +675,7 @@ export function createJournalStore() {
674
675
  try { st = stateOf(key); } catch (e) { return err("journal_export", id, "io", e.message); }
675
676
  const s = scan(st.path);
676
677
  const envelope = readEnvelope(st.dir);
677
- const head = JSON.stringify({ format: "carmar-history-export", schema: 1, key,
678
+ const head = JSON.stringify({ format: "beatrina-history-export", schema: 1, key,
678
679
  exported: Date.now(), checkpointRevision: st.checkpoint, meta: readMeta(st.dir) });
679
680
  const text = `${head.slice(0, head.length - 1)},"checkpoint":${envelope == null ? "null" : JSON.stringify(envelope)},"records":[${s.records.join(",")}]}`;
680
681
  if (bytesOf(text) > JOURNAL_MAX_REPLY_BYTES) {
package/host/main.mjs CHANGED
@@ -5,15 +5,18 @@
5
5
  // spike/serve.R does, so every launcher, fixture and agent that reads that
6
6
  // line works unchanged. Everything else goes to stderr.
7
7
  //
8
- // Env (the CARMAR_* names are the WIRE the page and its launchers speak; they
9
- // are kept on purpose — renaming them is WP7's job, with the launchers):
10
- // CARMAR_PORT (4747), CARMAR_PORT_STRICT=1, CARMAR_BIND, CARMAR_HOSTS …
11
- // (see deployment.mjs), CARMAR_RSCRIPT, CARMAR_KERNEL_BUILD,
12
- // CARMAR_INSTALLED_BUILD, CARMAR_LOG, CARMAR_RUNTIME_DIR,
13
- // CARMAR_NO_RUNTIME_FILE=1, CARMAR_LINGER, CARMAR_BOOT_GRACE,
14
- // CARMAR_WORKER_DEADLINE_SCALE, CARMAR_WORKER_RESPAWN_MAX, CARMAR_OPEN_FILE,
15
- // CARMAR_HOST_ROOT, CARMAR_KERNEL_DIR (WP7: where the files are when the
16
- // host is not running from a source checkout — see hostRoot/kernelDir).
8
+ // Env. The product's variables are `BEATRINA_*`; every one is still ACCEPTED
9
+ // under its old `CARMAR_*` spelling (host/env-names.mjs — `env()` below reads
10
+ // both, canonical first, and every call site may keep either spelling):
11
+ // BEATRINA_PORT (4747), BEATRINA_PORT_STRICT=1, BEATRINA_BIND,
12
+ // BEATRINA_HOSTS … (see deployment.mjs), BEATRINA_RSCRIPT,
13
+ // BEATRINA_KERNEL_BUILD, BEATRINA_INSTALLED_BUILD, BEATRINA_LOG,
14
+ // BEATRINA_RUNTIME_DIR (default ~/.beatrina/run; host/runtime-dir.mjs),
15
+ // BEATRINA_NO_RUNTIME_FILE=1, BEATRINA_LINGER, BEATRINA_BOOT_GRACE,
16
+ // BEATRINA_WORKER_DEADLINE_SCALE, BEATRINA_WORKER_RESPAWN_MAX,
17
+ // BEATRINA_OPEN_FILE, BEATRINA_HOST_ROOT, BEATRINA_KERNEL_DIR (WP7: where
18
+ // the files are when the host is not running from a source checkout — see
19
+ // hostRoot/kernelDir). Children are handed both spellings (mirrorEnv).
17
20
  //
18
21
  // PACKAGING (docs/packaging.md). Node's Single Executable Application runs its
19
22
  // main script as CommonJS — `embedderRunCjs`, measured on Node 25.5.0, an ESM
@@ -32,14 +35,17 @@ import { spawn } from "node:child_process";
32
35
  import net from "node:net";
33
36
  import os from "node:os";
34
37
  import path from "node:path";
35
- import sea from "node:sea";
36
38
  import { fileURLToPath } from "node:url";
37
- import { carmarDeployment } from "./deployment.mjs";
39
+ import { beatrinaDeployment } from "./deployment.mjs";
40
+ import { envReader, envSetName, hasEnv, readEnv, unsetEnv } from "./env-names.mjs";
41
+ import { runtimeWriteDir } from "./runtime-dir.mjs";
38
42
  import { REngine, detectRscript } from "./engine-r.mjs";
43
+ import { payloadFingerprint, payloadGuardOn, guardFactory } from "./payload.mjs";
39
44
  import { PythonEngine, detectPython } from "./engine-python.mjs";
40
45
  import { JsEngine, detectNode } from "./engine-js.mjs";
41
46
  import { EnginePool } from "./engine-pool.mjs";
42
47
  import { createHostServer, secureToken } from "./server.mjs";
48
+ import { gatewayCommands, loadGatewayToken } from "./gateway-token.mjs";
43
49
  import { decodeSessionDocuments, keepTokenOk, sessionDocumentsLabel } from "./session-keep.mjs";
44
50
  import { createSettings } from "./settings.mjs";
45
51
  // ── planes (one line per family; see the seam in server.mjs) ───────────────
@@ -49,27 +55,28 @@ import { createSettings } from "./settings.mjs";
49
55
  import * as planeAiStore from "./planes/ai-store.mjs";
50
56
  import * as planeAi from "./planes/ai.mjs";
51
57
  import * as planeAnalyze from "./planes/analyze.mjs";
58
+ import * as planeChoose from "./planes/choose.mjs";
52
59
  import * as planeCite from "./planes/cite.mjs";
53
60
  import * as planeLatex from "./planes/latex.mjs";
54
61
  import * as planeFiles from "./planes/files.mjs";
62
+ import * as planeBundle from "./planes/bundle.mjs";
55
63
  import * as planeJobs from "./planes/jobs.mjs";
56
64
  import * as planeJournal from "./planes/journal.mjs";
57
65
  import * as planeMcp from "./planes/mcp.mjs";
58
66
  import * as planePair from "./planes/pair.mjs";
59
67
  import * as planePlugins from "./planes/plugins.mjs";
68
+ import * as planeRecentDocuments from "./planes/recent-documents.mjs";
60
69
  import * as planeSessions from "./planes/sessions.mjs";
61
70
  import * as planeSettings from "./planes/settings.mjs";
62
71
  import * as planeTerminal from "./planes/terminal.mjs";
63
72
  import * as planeTestFile from "./planes/test-file.mjs";
64
73
  import * as planeUpdate from "./planes/update.mjs";
65
- import { appId } from "./user-dirs.mjs";
74
+ import { appId, rUserDir } from "./user-dirs.mjs";
66
75
  import { updateAvailable, updateRecordFile } from "./update-record.mjs";
67
- const PLANE_MODULES = [planeAiStore, planeAi, planeAnalyze, planeCite, planeFiles, planeJobs, planeJournal, planeLatex, planeMcp, planePair, planePlugins, planeSessions, planeSettings, planeTerminal, planeTestFile, planeUpdate];
76
+ const PLANE_MODULES = [planeAiStore, planeAi, planeAnalyze, planeBundle, planeChoose, planeCite, planeFiles, planeJobs, planeJournal, planeLatex, planeMcp, planePair, planePlugins, planeRecentDocuments, planeSessions, planeSettings, planeTerminal, planeTestFile, planeUpdate];
68
77
 
69
- const env = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
78
+ const env = envReader(process.env);
70
79
 
71
- /** Whether this process is a packaged single executable, asked once. */
72
- const packaged = (() => { try { return sea.isSea(); } catch { return false; } })();
73
80
 
74
81
  /** This module's own directory — "" when the cjs bundler emptied import.meta. */
75
82
  function moduleDir() {
@@ -81,14 +88,16 @@ function moduleDir() {
81
88
  * Where the host's own files live.
82
89
  *
83
90
  * A source checkout answers from this module's location (host/.. = the repo).
84
- * A PACKAGED binary has no module path on disk, so it answers from the folder
85
- * it sits in — which is also the rule the page pin needs: `carmar_V<build>.html`
86
- * beside the binary, exactly as spike/notebook-page.R puts it beside kernel/.
91
+ * Anything with no module path on disk answers from the folder it sits in —
92
+ * which is also the rule the page pin needs: `beatrina_V<build>.html` beside
93
+ * the host, exactly as spike/notebook-page.R puts it beside kernel/. There was
94
+ * a `sea.isSea()` rung above this one; it dispatched to the SAME answer the
95
+ * moduleDir() fallback already gives, and the node:sea build it served is
96
+ * retired (f5979e5, 2026-09-16).
87
97
  */
88
98
  function hostRoot() {
89
- const dictated = env("CARMAR_HOST_ROOT").trim();
99
+ const dictated = env("BEATRINA_HOST_ROOT").trim();
90
100
  if (dictated) return path.resolve(dictated);
91
- if (packaged) return path.dirname(process.execPath);
92
101
  const mine = moduleDir();
93
102
  if (mine) return path.resolve(mine, "..");
94
103
  return path.dirname(process.execPath);
@@ -99,10 +108,10 @@ function hostRoot() {
99
108
  *
100
109
  * `spike/` in the repo; `kernel/` in every distribution, because that is what
101
110
  * tools/ship/build-r-pkg.sh and tools/app/build-app.sh have always called it.
102
- * Named by CARMAR_KERNEL_DIR when a launcher knows better.
111
+ * Named by BEATRINA_KERNEL_DIR when a launcher knows better.
103
112
  */
104
113
  function kernelDir(base) {
105
- const dictated = env("CARMAR_KERNEL_DIR").trim();
114
+ const dictated = env("BEATRINA_KERNEL_DIR").trim();
106
115
  if (dictated) return path.resolve(dictated);
107
116
  for (const name of ["spike", "kernel"]) {
108
117
  if (fs.existsSync(path.join(base, name, "worker-boot.R"))) return path.join(base, name);
@@ -131,15 +140,15 @@ function sourceVersion() {
131
140
  return m ? m[1] : "unknown";
132
141
  }
133
142
  function readKernelBuild() {
134
- const dictated = env("CARMAR_KERNEL_BUILD").trim();
143
+ const dictated = env("BEATRINA_KERNEL_BUILD").trim();
135
144
  if (dictated) return dictated;
136
145
  const stamp = path.join(spike, "kernel-version");
137
146
  if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
138
147
  return sourceVersion();
139
148
  }
140
149
  function installedBuild() {
141
- const dictated = env("CARMAR_KERNEL_BUILD").trim();
142
- if (dictated) return env("CARMAR_INSTALLED_BUILD").trim() || dictated;
150
+ const dictated = env("BEATRINA_KERNEL_BUILD").trim();
151
+ if (dictated) return env("BEATRINA_INSTALLED_BUILD").trim() || dictated;
143
152
  const stamp = path.join(spike, "kernel-version");
144
153
  if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
145
154
  return sourceVersion();
@@ -155,11 +164,16 @@ function installedBuild() {
155
164
  * alone, which is a difference in the fixture's eyes and not in the design's.
156
165
  */
157
166
  function notebookPage(build, { fallback = true } = {}) {
158
- const wanted = `carmar_V${build}.html`;
167
+ const wanted = `beatrina_V${build}.html`;
159
168
  // serve.R's pin (spike/notebook-page.R): beside the KERNEL directory, not the
160
169
  // host's root — the two differ exactly when the kernel files are a staged or
161
- // installed copy, which is the shape every upgrade has.
162
- for (const p of [path.join(spike, "..", "dist", wanted), path.join(spike, "..", wanted)]) if (fs.existsSync(p)) return path.resolve(p);
170
+ // installed copy, which is the shape every upgrade has. Third, the copy the
171
+ // `beatrina` command keeps per version under the app's cache (tools/npm/bin/pages.mjs):
172
+ // npm replaces the package folder wholesale, so after `npm install -g beatrina@<next>`
173
+ // the page beside kernel/ is the NEW version's and a session still running the old one
174
+ // would serve the development index on every GET / (verified live 2026-09-16). The
175
+ // installed page beside kernel/ stays what the handoff and the "Restart into" door read.
176
+ for (const p of [path.join(spike, "..", "dist", wanted), path.join(spike, "..", wanted), path.join(rUserDir(appId(), "cache"), "pages", wanted)]) if (fs.existsSync(p)) return path.resolve(p);
163
177
  if (!fallback) return "";
164
178
  const dev = path.join(spike, "index.html");
165
179
  return fs.existsSync(dev) ? dev : "";
@@ -177,6 +191,41 @@ function notebookFileUrl(p, port, cap) {
177
191
  return `${prefix}${encodeURI(slashed)}#kernel=${port}${cap ? `&pair=${cap}` : ""}`;
178
192
  }
179
193
 
194
+ // ── the handoff successor's preflight ───────────────────────────────────────
195
+ /**
196
+ * Everything a successor needs to serve the session it is taking over, checked while the
197
+ * predecessor is still serving it: its page, its worker, and an R that runs. A protocol or build
198
+ * stamp that cannot be read has already thrown by the time this is called (main's first lines),
199
+ * which the predecessor sees as an exit before `ready`.
200
+ * @returns {Promise<{ok:boolean, error?:string, build:string, pid:number}>}
201
+ */
202
+ async function handoffPreflight(kernelBuild, settings) {
203
+ const fail = (error) => ({ ok: false, error, build: kernelBuild, pid: process.pid });
204
+ if (!notebookPage(kernelBuild, { fallback: false })) return fail(`it has no notebook page for Beatrina ${kernelBuild}`);
205
+ if (!fs.existsSync(path.join(spike, "worker-boot.R"))) return fail(`its R worker is missing (${path.join(spike, "worker-boot.R")})`);
206
+ const rscript = detectRscript(settings.rscriptPreference());
207
+ if (!rscript) return fail("it cannot find R");
208
+ const { execFile } = await import("node:child_process");
209
+ const ran = await new Promise((resolve) => {
210
+ execFile(rscript, ["--version"], { timeout: 10000 }, (err, stdout, stderr) => {
211
+ resolve(err ? `${err.message}`.split("\n")[0] : /version \d/.test(`${stdout}${stderr}`) ? "" : "it did not say which R it is");
212
+ });
213
+ });
214
+ if (ran) return fail(`its R (${rscript}) would not run: ${ran}`);
215
+ return { ok: true, build: kernelBuild, pid: process.pid };
216
+ }
217
+
218
+ /** The ready file, replaced atomically so the predecessor never reads half of it. */
219
+ function writeHandoffReady(file, verdict) {
220
+ const tmp = `${file}.${process.pid}.tmp`;
221
+ try {
222
+ fs.writeFileSync(tmp, JSON.stringify(verdict), { mode: 0o600 });
223
+ fs.renameSync(tmp, file);
224
+ } catch (e) {
225
+ console.error(`Beatrina: could not write the handoff ready file: ${e.message}`);
226
+ }
227
+ }
228
+
180
229
  // ── the port: a preference by default, a promise when asked ────────────────
181
230
  const portTaken = (p) => new Promise((resolve) => {
182
231
  const s = net.connect({ port: p, host: "127.0.0.1" });
@@ -186,10 +235,10 @@ const portTaken = (p) => new Promise((resolve) => {
186
235
  s.once("timeout", () => { s.destroy(); resolve(false); });
187
236
  });
188
237
  async function choosePort() {
189
- let wanted = Number.parseInt(env("CARMAR_PORT", "4747"), 10);
238
+ let wanted = Number.parseInt(env("BEATRINA_PORT", "4747"), 10);
190
239
  if (!Number.isInteger(wanted) || wanted < 1024 || wanted > 65535) wanted = 4747;
191
240
  if (!(await portTaken(wanted))) return wanted;
192
- const waitS = Number(env("CARMAR_WAIT_PORT", "0"));
241
+ const waitS = Number(env("BEATRINA_WAIT_PORT", "0"));
193
242
  if (Number.isFinite(waitS) && waitS > 0) {
194
243
  const deadline = Date.now() + waitS * 1000;
195
244
  while (Date.now() < deadline) { // a wait is a loop
@@ -197,28 +246,50 @@ async function choosePort() {
197
246
  if (!(await portTaken(wanted))) return wanted;
198
247
  }
199
248
  }
200
- if (env("CARMAR_PORT_STRICT") === "1") {
201
- console.error(`Beatrina refuses to start: CARMAR_PORT=${wanted} is already in use and CARMAR_PORT_STRICT=1 forbids moving to another port.`);
249
+ if (env("BEATRINA_PORT_STRICT") === "1") {
250
+ console.error(`Beatrina refuses to start: ${envSetName(process.env, "PORT")}=${wanted} is already in use and ${envSetName(process.env, "PORT_STRICT")}=1 forbids moving to another port.`);
202
251
  process.exit(2);
203
252
  }
204
- // Scan the session band, then let the OS pick.
205
- for (let p = 4747; p <= 4947; p += 1) if (!(await portTaken(p))) return p;
253
+ // Scan the session band — BEATRINA_SESSION_BASE up, 4747..4947 by default, the ONE rule the
254
+ // sibling a page starts reads too (planes/sessions.mjs sessionBase) — from the WANTED port
255
+ // upward to the top, then from the base up to it, then let the OS pick. So a second `beatrina`
256
+ // (wanted 4848) is 4849, and 4747 — Beatrina's and the published pages' port, whose origin keys
257
+ // a browser's storage — is the last resort, not the first (docs/launch-menu-plan.md L2). With
258
+ // nothing set the default stays 4747, and a wanted port outside the band scans the band as before.
259
+ const base = planeSessions.sessionBase(env);
260
+ const top = base + planeSessions.SESSION_BAND - 1;
261
+ const order = wanted >= base && wanted <= top
262
+ ? [...range(wanted + 1, top), ...range(base, wanted - 1)]
263
+ : range(base, top);
264
+ for (const p of order) if (!(await portTaken(p))) return p;
206
265
  return 0;
207
266
  }
267
+ /** The integers from `lo` to `hi`, both included; empty when `hi < lo`. */
268
+ const range = (lo, hi) => Array.from({ length: Math.max(0, hi - lo + 1) }, (_, i) => lo + i);
208
269
 
209
270
  // The whole start-up is a FUNCTION, not a script: Node SEA runs its main as
210
271
  // CommonJS and esbuild refuses top-level await in cjs output (docs/packaging.md).
211
272
  // Nothing below may move back out to the module body.
212
273
  async function main() {
274
+ // A configured gateway is the sole runtime entry point, including on shared loopback.
275
+ const gatewayToken = loadGatewayToken(hasEnv(process.env, "GATEWAY_TOKEN_FILE") ? readEnv(process.env, "GATEWAY_TOKEN_FILE") : undefined);
276
+ // Neither worker environments nor tools launched by planes need this credential's path.
277
+ unsetEnv(process.env, "GATEWAY_TOKEN_FILE");
213
278
  const protocol = readProtocol();
214
279
  const kernelBuild = readKernelBuild();
280
+ // The payload this supervisor boots with, fingerprinted once: every worker it
281
+ // starts later is refused if the files under it have since been replaced
282
+ // (host/payload.mjs, docs/runplan.md R6).
283
+ const payloadGuard = payloadGuardOn(spike, env("BEATRINA_PAYLOAD_GUARD").trim());
284
+ const measurePayload = () => payloadFingerprint({ kernelDir: spike, root });
285
+ const bootPayload = payloadGuard ? measurePayload() : null;
215
286
  // A newer Beatrina on npm, as the `beatrina` command last recorded it (host/update-record.mjs):
216
- // the host reads that record and never the registry. Null for CarmaR's doors, which write none.
287
+ // the host reads that record and never the registry. Null for Beatrina's doors, which write none.
217
288
  const newerOnNpm = () => updateAvailable({ file: updateRecordFile(appId()), current: kernelBuild });
218
289
  // WP5a — the user's settings, and the administrator's managed policy.
219
290
  // Created HERE, before the port and the deployment, because reading the managed
220
291
  // file puts its values into the environment (serve.R does the same at its line
221
- // 63): CARMAR_PORT, CARMAR_HOSTS and CARMAR_BIND may all come from it, and the
292
+ // 63): BEATRINA_PORT, BEATRINA_HOSTS and BEATRINA_BIND may all come from it, and the
222
293
  // env-wins-and-locks rule is only a single source of truth if the environment
223
294
  // is already complete when the first subsystem reads it. Its errors refuse
224
295
  // startup for the deployment's reason: a governance control that silently
@@ -235,15 +306,27 @@ async function main() {
235
306
  for (const m of settings.errors) console.error(`Beatrina refuses to start: ${m}`);
236
307
  process.exit(2);
237
308
  }
309
+ // A handoff successor PROVES it can run before it waits for the port its predecessor still
310
+ // holds (planes/sessions.mjs handoffBegin, phase 1). The predecessor retires only on `ok`.
311
+ const handoffReady = env("BEATRINA_HANDOFF_READY").trim();
312
+ unsetEnv(process.env, "HANDOFF_READY");
313
+ if (handoffReady) {
314
+ const verdict = await handoffPreflight(kernelBuild, settings);
315
+ writeHandoffReady(handoffReady, verdict);
316
+ if (!verdict.ok) {
317
+ console.error(`Beatrina will not take over the session: ${verdict.error}`);
318
+ process.exit(2);
319
+ }
320
+ }
238
321
  const port = await choosePort();
239
- const deployment = carmarDeployment(port, env);
322
+ const deployment = beatrinaDeployment(port, env);
240
323
  if (deployment.errors.length) {
241
324
  for (const m of deployment.errors) console.error(`Beatrina refuses to start: ${m}`);
242
325
  process.exit(2);
243
326
  }
244
327
 
245
328
  // ── the audit log ──────────────────────────────────────────────────────────
246
- const logPath = env("CARMAR_LOG");
329
+ const logPath = env("BEATRINA_LOG");
247
330
  const audit = (event, fields = {}) => {
248
331
  if (!logPath) return;
249
332
  const rec = { ts: new Date().toISOString(), pid: process.pid, event, ...fields };
@@ -257,17 +340,17 @@ async function main() {
257
340
  // and tens of megabytes spent on a document that may hold no Python at all.
258
341
  const workerPath = path.join(spike, "worker-boot.R");
259
342
  const pythonWorker = path.join(root, "engines", "python", "worker.py");
260
- const deadlineScale = Number(env("CARMAR_WORKER_DEADLINE_SCALE", "1")) > 0 ? Number(env("CARMAR_WORKER_DEADLINE_SCALE", "1")) : 1;
261
- const respawnMax = Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) >= 1 ? Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) : 10;
343
+ const deadlineScale = Number(env("BEATRINA_WORKER_DEADLINE_SCALE", "1")) > 0 ? Number(env("BEATRINA_WORKER_DEADLINE_SCALE", "1")) : 1;
344
+ const respawnMax = Number.parseInt(env("BEATRINA_WORKER_RESPAWN_MAX", "10"), 10) >= 1 ? Number.parseInt(env("BEATRINA_WORKER_RESPAWN_MAX", "10"), 10) : 10;
262
345
  const childEnv = {};
263
- if (env("CARMAR_ROOT")) childEnv.CARMAR_ROOT = env("CARMAR_ROOT");
346
+ if (env("BEATRINA_ROOT")) childEnv.BEATRINA_ROOT = env("BEATRINA_ROOT");
264
347
  // share()/use(): the one explicit bridge between the session's languages.
265
348
  // One 0700 directory per host, shared by every engine it starts, removed at
266
349
  // exit. A value crosses only when a chunk names it (engines/js/worker.mjs,
267
350
  // engines/r/handoff.R); nothing is ever copied implicitly.
268
351
  const handoffDir = fs.mkdtempSync(path.join(os.tmpdir(), "beatrix-handoff-"));
269
352
  fs.chmodSync(handoffDir, 0o700);
270
- childEnv.CARMAR_HANDOFF_DIR = handoffDir;
353
+ childEnv.BEATRINA_HANDOFF_DIR = handoffDir; // engineEnvironment mirrors it under the older spelling too
271
354
  process.on("exit", () => { try { fs.rmSync(handoffDir, { recursive: true, force: true }); } catch { /* best effort at exit */ } });
272
355
  const planes = PLANE_MODULES.map((mod) => (mod.createPlane ? mod.createPlane({ root, spike, env, audit, settings }) : mod.default));
273
356
  let servedCommands = [];
@@ -276,37 +359,40 @@ async function main() {
276
359
  // frame is what decides whether the page greys a {python} chunk, and a list
277
360
  // that filled in half a second later would grey it and then quietly change
278
361
  // its mind. Measured: 130–260 ms for the whole ladder.
279
- const pythonFound = env("CARMAR_NO_PYTHON") === "1"
280
- ? { available: false, detail: "The Python engine is disabled (CARMAR_NO_PYTHON=1).", searched: [] }
281
- : await detectPython(process.env);
362
+ const pythonFound = env("BEATRINA_NO_PYTHON") === "1"
363
+ ? { available: false, detail: `The Python engine is disabled (${envSetName(process.env, "NO_PYTHON")}=1).`, searched: [] }
364
+ // The project rung of the ladder looks beside the document being opened,
365
+ // else beside the working directory (a `.venv` there is the user's answer).
366
+ : await detectPython(process.env, { root: env("BEATRINA_OPEN_FILE") ? path.dirname(env("BEATRINA_OPEN_FILE")) : process.cwd() });
282
367
  audit("engine-discovery", { engine: "python", available: pythonFound.available, detail: pythonFound.detail });
283
368
  const jsWorker = path.join(root, "engines", "js", "worker.mjs");
284
- const nodeFound = env("CARMAR_NO_JS") === "1"
285
- ? { available: false, detail: "The JavaScript engine is disabled (CARMAR_NO_JS=1).", path: "", version: "" }
286
- : !packaged && !fs.existsSync(jsWorker)
369
+ const nodeFound = env("BEATRINA_NO_JS") === "1"
370
+ ? { available: false, detail: `The JavaScript engine is disabled (${envSetName(process.env, "NO_JS")}=1).`, path: "", version: "" }
371
+ : !fs.existsSync(jsWorker)
287
372
  ? { available: false, detail: `This Beatrina does not carry the JavaScript worker (${jsWorker}).`, path: "", version: "" }
288
- : detectNode({ env: process.env, packaged });
373
+ : detectNode({ env: process.env });
289
374
  audit("engine-discovery", { engine: "js", available: nodeFound.available, detail: nodeFound.detail });
375
+ const pinned = (engine, make) => guardFactory(make, { enabled: payloadGuard, boot: bootPayload, bootBuild: kernelBuild, measure: measurePayload, audit, engine });
290
376
 
291
377
  const ENGINE_SPECS = [
292
378
  // WP5a — which R, and what the child inherits, come from the resolved
293
379
  // settings (`rscript_path`, the child env). Read per spawn, never
294
380
  // captured, so Restart R picks up a changed binary.
295
381
  { name: "r", label: "R", boot: "eager", available: true,
296
- factory: () => new REngine({
382
+ factory: pinned("r", () => new REngine({
297
383
  workerPath, handoffScript: path.join(root, "engines", "r", "handoff.R"),
298
384
  rscript: detectRscript(settings.rscriptPreference()),
299
385
  env: { ...childEnv, ...settings.childEnv() },
300
- }) },
386
+ })) },
301
387
  { name: "python", label: "Python", boot: "on-demand",
302
388
  available: pythonFound.available, detail: pythonFound.detail,
303
389
  version: pythonFound.version || "",
304
390
  runtime: { path: pythonFound.path || "", why: pythonFound.why || "", searched: pythonFound.searched },
305
- factory: () => new PythonEngine({ workerPath: pythonWorker, python: pythonFound.path, env: { ...childEnv, ...settings.childEnv() } }) },
391
+ factory: pinned("python", () => new PythonEngine({ workerPath: pythonWorker, python: pythonFound.path, env: { ...childEnv, ...settings.childEnv() } })) },
306
392
  { name: "js", label: "JavaScript", boot: "on-demand",
307
393
  available: nodeFound.available, detail: nodeFound.detail, version: nodeFound.version || "",
308
394
  runtime: { path: nodeFound.path || "", why: nodeFound.detail },
309
- factory: () => new JsEngine({ workerPath: jsWorker, node: nodeFound.path, selfHosted: Boolean(nodeFound.selfHosted), env: { ...childEnv, ...settings.childEnv() } }) },
395
+ factory: pinned("js", () => new JsEngine({ workerPath: jsWorker, node: nodeFound.path, env: { ...childEnv, ...settings.childEnv() } })) },
310
396
  ];
311
397
 
312
398
  // `/health r` before the worker is ready: ask the chosen Rscript once, in
@@ -333,10 +419,10 @@ async function main() {
333
419
  // Per engine, never merged: `commands` is a contract about what THIS
334
420
  // session answers, and lib/kernel-capability.js refuses an op by name on
335
421
  // the strength of it. The host's own verbs are added to the primary's.
336
- commands: [...new Set([
422
+ commands: gatewayCommands([...new Set([
337
423
  ...(Array.isArray(frame.commands) ? frame.commands : [frame.commands]).filter(Boolean),
338
424
  ...(spec.name === plane.primaryName ? ["session_restart", ...servedCommands] : []),
339
- ])],
425
+ ])], gatewayToken !== undefined),
340
426
  features: Array.isArray(frame.features) ? frame.features : (frame.features ? [frame.features] : []),
341
427
  engine: spec.name,
342
428
  language: frame.language || spec.name,
@@ -348,33 +434,52 @@ async function main() {
348
434
  }),
349
435
  });
350
436
  // Set before the worker starts, so its first ready frame cannot arrive ahead of the token.
351
- if (keepTokenOk(env("CARMAR_RESUME_TOKEN"))) plane.setResumePending(env("CARMAR_RESUME_TOKEN"));
352
- delete process.env.CARMAR_RESUME_TOKEN;
437
+ if (keepTokenOk(env("BEATRINA_RESUME_TOKEN"))) plane.setResumePending(env("BEATRINA_RESUME_TOKEN"));
438
+ unsetEnv(process.env, "RESUME_TOKEN");
353
439
  await plane.start();
354
440
 
355
441
  // ── the server ─────────────────────────────────────────────────────────────
356
442
  const bootAt = Date.now();
357
- const fileLaunchCap = /^[A-Za-z0-9]{32}$/.test(env("CARMAR_FILE_LAUNCH_CAP")) ? env("CARMAR_FILE_LAUNCH_CAP") : secureToken(32);
358
- delete process.env.CARMAR_FILE_LAUNCH_CAP;
443
+ const fileLaunchCap = /^[A-Za-z0-9]{32}$/.test(env("BEATRINA_FILE_LAUNCH_CAP")) ? env("BEATRINA_FILE_LAUNCH_CAP") : secureToken(32);
444
+ unsetEnv(process.env, "FILE_LAUNCH_CAP");
359
445
  let runtimeFile = "";
360
446
  // `app` says whose session this is (BEATRIX_APP_ID): a double-clicked document
361
- // is handed to a running session of the SAME app, never to CarmaR's.
447
+ // is handed to a running session of the SAME app, never to Beatrina's.
362
448
  const runtimeRecord = { url: "", file: "", port, pid: process.pid, started: new Date(bootAt).toISOString(), host: "beatrix", app: appId() };
449
+ // Which gesture started this session (launch.sh's per-launch id and the door it came through,
450
+ // docs/runplan.md R5): "which double-click made this R?" is one grep across launches.log, this
451
+ // record and the audit log. Taken OUT of the environment so a sibling or a worker does not
452
+ // inherit a gesture that was not theirs.
453
+ const launchId = env("BEATRINA_LAUNCH_ID").trim().replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64);
454
+ const launchVia = env("BEATRINA_LAUNCH_VIA").trim().replace(/[^A-Za-z0-9._-]/g, "").slice(0, 64);
455
+ unsetEnv(process.env, "LAUNCH_ID");
456
+ unsetEnv(process.env, "LAUNCH_VIA");
457
+ if (launchId) runtimeRecord.launch = launchId;
458
+ if (launchVia) runtimeRecord.launch_via = launchVia;
363
459
  // The document the session was started with, for `beatrina status`.
364
- if (env("CARMAR_OPEN_FILE")) runtimeRecord.document = env("CARMAR_OPEN_FILE");
460
+ if (env("BEATRINA_OPEN_FILE")) runtimeRecord.document = env("BEATRINA_OPEN_FILE");
365
461
  // A successor started by a handoff keeps the session's name.
366
- const inheritedTitle = env("CARMAR_SESSION_TITLE").trim();
462
+ const inheritedTitle = env("BEATRINA_SESSION_TITLE").trim();
367
463
  if (inheritedTitle) runtimeRecord.title = inheritedTitle.slice(0, 120);
368
464
  // A handoff successor starts with its predecessor's documents and, for "Restart, keep variables", the
369
465
  // token its first worker restores (host/session-keep.mjs). Only a token travels, never a path.
370
- const inheritedDocuments = decodeSessionDocuments(env("CARMAR_SESSION_DOCUMENTS"));
371
- delete process.env.CARMAR_SESSION_DOCUMENTS;
466
+ const inheritedDocuments = decodeSessionDocuments(env("BEATRINA_SESSION_DOCUMENTS"));
467
+ unsetEnv(process.env, "SESSION_DOCUMENTS");
372
468
  if (sessionDocumentsLabel(inheritedDocuments)) runtimeRecord.documents = sessionDocumentsLabel(inheritedDocuments);
373
469
 
374
- const handoffFrom = env("CARMAR_HANDOFF_FROM").trim();
470
+ const handoffFrom = env("BEATRINA_HANDOFF_FROM").trim();
471
+ // Why this returns the REASON and not just false: the record is how every
472
+ // other tool — `beatrina status`, the Sessions menu, the MCP bridge — learns
473
+ // this session exists, so a session that cannot write one is discoverable by
474
+ // nobody. It still serves (a notebook already open must not lose its R over
475
+ // a full disk), but it may not do so in silence: the caller says so on
476
+ // stderr and in the audit log. Swallowed until 2026-09-21, which is a
477
+ // running supervisor that no door can find and no line anywhere saying why.
478
+ let runtimeWriteError = "";
375
479
  const writeRuntime = () => {
376
480
  if (!runtimeFile) return false;
377
- try { fs.writeFileSync(runtimeFile, JSON.stringify(runtimeRecord), { mode: 0o600 }); return true; } catch { return false; }
481
+ try { fs.writeFileSync(runtimeFile, JSON.stringify(runtimeRecord), { mode: 0o600 }); runtimeWriteError = ""; return true; }
482
+ catch (e) { runtimeWriteError = (e && e.message) || String(e); return false; }
378
483
  };
379
484
  let stopping = false;
380
485
  async function shutdown(reason) {
@@ -397,6 +502,7 @@ async function main() {
397
502
  process.exit(0);
398
503
  }
399
504
  const host = createHostServer({
505
+ gatewayToken,
400
506
  deployment, plane, audit, protocol, kernelBuild, installedBuild, updateAvailable: newerOnNpm, bootAt, app: appId(), fileLaunchCap, here: spike, planes,
401
507
  rVersionHint: () => rVersionHint,
402
508
  pagePath: () => notebookPage(kernelBuild),
@@ -405,7 +511,7 @@ async function main() {
405
511
  // session, so no fallback may answer "the installed build has a page".
406
512
  notebookPage: (build) => notebookPage(build, { fallback: false }), notebookFileUrl: (p, prt, cap) => notebookFileUrl(path.resolve(p), prt, cap),
407
513
  runtimeRecord: () => runtimeRecord, handoffFrom,
408
- pendingOpen: env("CARMAR_OPEN_FILE") || null,
514
+ pendingOpen: env("BEATRINA_OPEN_FILE") || null,
409
515
  sessionDocuments: inheritedDocuments,
410
516
  // The menu helper names a session by its document ("SezerProfiles.qmd + 1 more").
411
517
  onSessionDocuments: (documents) => {
@@ -449,14 +555,24 @@ async function main() {
449
555
  const fileUrl = page ? notebookFileUrl(path.resolve(page), boundPort, fileLaunchCap) : "";
450
556
  runtimeRecord.url = url;
451
557
  runtimeRecord.file = fileUrl;
452
- if (env("CARMAR_LISTEN") === "1") runtimeRecord.listen = true;
453
- if (env("CARMAR_NO_RUNTIME_FILE") !== "1") {
454
- const dir = env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
455
- try { fs.mkdirSync(dir, { recursive: true }); runtimeFile = path.join(dir, `kernel-${boundPort}.json`); } catch { runtimeFile = ""; }
558
+ if (env("BEATRINA_LISTEN") === "1") runtimeRecord.listen = true;
559
+ if (env("BEATRINA_NO_RUNTIME_FILE") !== "1") {
560
+ const dir = runtimeWriteDir(process.env);
561
+ try { fs.mkdirSync(dir, { recursive: true }); runtimeFile = path.join(dir, `kernel-${boundPort}.json`); }
562
+ catch (e) { runtimeFile = ""; runtimeWriteError = (e && e.message) || String(e); }
456
563
  if (runtimeFile && !writeRuntime()) runtimeFile = "";
564
+ if (!runtimeFile) {
565
+ // Serving and undiscoverable: say it once, where a person and the log
566
+ // both see it. `BEATRINA_NO_RUNTIME_FILE=1` is the way to ask for this
567
+ // on purpose, and takes the branch above instead of this one.
568
+ const detail = runtimeWriteError || "the directory could not be created";
569
+ console.error(`Beatrina: this session could not write its discovery record in ${dir} (${detail}). `
570
+ + "It is serving, but `beatrina status`, the Sessions menu and the agent (MCP) bridge will not find it.");
571
+ audit("runtime-record-failed", { dir, port: boundPort, detail });
572
+ }
457
573
  }
458
574
  audit("listening", { bind: deployment.bind, port: boundPort, loopback: deployment.loopback, require_origin: deployment.require_origin, host: "beatrix" });
459
- audit("started", { port: boundPort, root: env("CARMAR_ROOT") });
575
+ audit("started", { port: boundPort, root: env("BEATRINA_ROOT"), launch: launchId || null, launch_via: launchVia || null });
460
576
  process.stdout.write(`${JSON.stringify({ url, file: fileUrl })}\n`);
461
577
  // --open: open the notebook FILE, as serve.R did for the double-click
462
578
  // launchers. --open=url opens the page THIS kernel serves instead
@@ -464,12 +580,12 @@ async function main() {
464
580
  // the page connects with no fragment and no pairing. The `beatrina` command
465
581
  // uses it, because macOS `open` and Finder drop a file URL's #kernel=…&pair=…
466
582
  // fragment and the page then cannot find its kernel ("needs R", 2026-09-16).
467
- // The kernel stays hidden plumbing. CARMAR_OPENER is the seam a test uses so
583
+ // The kernel stays hidden plumbing. BEATRINA_OPENER is the seam a test uses so
468
584
  // nothing opens on a real desktop.
469
585
  const openArg = process.argv.slice(2).find((a) => a === "--open" || a === "--open=url" || a === "--open=file");
470
586
  if (openArg) {
471
587
  const target = openArg === "--open=url" ? url : (fileUrl || url);
472
- const opener = String(process.env.CARMAR_OPENER || "");
588
+ const opener = readEnv(process.env, "OPENER");
473
589
  try {
474
590
  const child = path.isAbsolute(opener) ? spawn(opener, [target], { stdio: "ignore", detached: true })
475
591
  : process.platform === "darwin" ? spawn("open", [target], { stdio: "ignore", detached: true })
@@ -485,13 +601,13 @@ async function main() {
485
601
  // grace instead, so an unopened launch does not leave an R behind — unless the
486
602
  // setting is explicitly 0, which says "headless, keep".
487
603
  //
488
- // WP5a — the clock reads the RESOLVED setting rather than CARMAR_LINGER, so the
604
+ // WP5a — the clock reads the RESOLVED setting rather than BEATRINA_LINGER, so the
489
605
  // user's settings.json can turn it on and Settings can change it live. "Still
490
606
  // at its default" is the exact rule serve.R's resolve_linger() uses for "never
491
607
  // reap": an explicit 0 from the env or the file is a CHOICE and is honoured as
492
608
  // one, which is why it is `source === "default"` and not `value === 0`. Read
493
609
  // per tick, never captured, so a settings_set takes effect without a restart.
494
- const bootGraceS = Number.isFinite(Number(env("CARMAR_BOOT_GRACE", "180"))) ? Number(env("CARMAR_BOOT_GRACE", "180")) : 180;
610
+ const bootGraceS = Number.isFinite(Number(env("BEATRINA_BOOT_GRACE", "180"))) ? Number(env("BEATRINA_BOOT_GRACE", "180")) : 180;
495
611
  let idleSince = null;
496
612
  setInterval(() => {
497
613
  const lingerS = settings.lingerSeconds();