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
@@ -18,42 +18,74 @@ import { execFile } from "node:child_process";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
20
  import { StdioEngine } from "./engine-stdio.mjs";
21
+ import { envSetName, readEnv } from "./env-names.mjs";
21
22
  import { findInterruptTool } from "./windows-runtime.mjs";
22
23
 
23
24
  /** How long a probe of a candidate interpreter may take before it is "no". */
24
25
  export const PROBE_TIMEOUT_MS = 6000;
25
26
 
26
27
  /** Python's own variables that would aim a child at a different install. */
27
- const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
28
+ export const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
28
29
 
29
30
  const exists = (p) => Boolean(p) && fs.existsSync(p);
30
31
 
32
+ /**
33
+ * The managed interpreter's home: `~/.beatrina/python`, a venv that
34
+ * `beatrina python setup` creates (tools/npm/bin/python-setup.mjs). No older
35
+ * home is searched: a venv is not relocatable and nobody was set up before this
36
+ * name, so a machine with an older folder runs `beatrina python setup` once.
37
+ */
38
+ export const MANAGED_PYTHON_DIRS = Object.freeze([
39
+ { dir: [".beatrina", "python"], why: "the interpreter Beatrina manages (~/.beatrina/python)" },
40
+ ]);
41
+
42
+ /** The interpreter inside a venv directory, by platform. */
43
+ export function venvInterpreter(dir, platform = process.platform) {
44
+ return platform === "win32" ? path.win32.join(dir, "Scripts", "python.exe") : path.join(dir, "bin", "python3");
45
+ }
46
+
47
+ /** The venv directories a project keeps beside its documents, in the order they are tried. */
48
+ export const PROJECT_VENV_DIRS = Object.freeze([".venv", "venv"]);
49
+
31
50
  /**
32
51
  * Every interpreter worth asking, in the order the answer is preferred, each
33
52
  * labelled with WHY it was considered. The label is what Doctor prints, so a
34
53
  * user who gets the wrong Python can see which rung chose it.
35
54
  *
36
- * `CARMAR_PYTHON` first (an explicit answer outranks a search), then the
37
- * environment the user is already inside — an active venv, then conda — then
38
- * pyenv's selected version, then PATH, then the usual installs. PATH is late
39
- * for the reason `detectRscript` puts it last: a Finder-launched process has
55
+ * `BEATRINA_PYTHON` first (an explicit answer outranks a search; the older
56
+ * spelling of the variable is accepted, host/env-names.mjs), then the
57
+ * interpreter Beatrina manages (`~/.beatrina/python`) — it outranks
58
+ * everything the user did not name because it is the one `beatrina python
59
+ * setup` built for exactly this — then the project's own venv (`.venv` or
60
+ * `venv` under `root`, the session's document directory), then the environment
61
+ * the user is already inside — an active venv, then conda — then pyenv's
62
+ * selected version, then PATH, then the usual installs. PATH is late for the
63
+ * reason `detectRscript` puts it last: a Finder-launched process has
40
64
  * `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, conda and pyenv
41
65
  * entirely, so a shell-launched and a Finder-launched session would silently
42
66
  * pick different interpreters.
43
67
  *
68
+ * @param {Record<string,string|undefined>} env
69
+ * @param {string} platform
70
+ * @param {{readdir: (p: string) => string[]}|null} io Windows only: a fake fs for tests
71
+ * @param {{root?: string}} [opts] `root`: the directory whose `.venv`/`venv` is a candidate
44
72
  * @returns {Array<{path: string, why: string}>}
45
73
  */
46
- export function pythonCandidates(env = process.env, platform = process.platform, io = null) {
47
- if (platform === "win32") return windowsPythonCandidates(env, io);
74
+ export function pythonCandidates(env = process.env, platform = process.platform, io = null, { root = "" } = {}) {
75
+ if (platform === "win32") return windowsPythonCandidates(env, io, { root });
48
76
  const binName = "python3";
49
77
  const rungs = [];
50
78
  const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
51
- if (env.CARMAR_PYTHON) add(env.CARMAR_PYTHON, "CARMAR_PYTHON");
52
- // An interpreter Beatrix installed for itself. Nothing creates this yet —
53
- // WP7 packages it — but the rung is here so that when something does, it
54
- // outranks whatever happens to be on the user's PATH, which is the whole
55
- // point of shipping one.
56
- if (env.HOME) add(path.join(env.HOME, ".carmar", "python", "bin", binName), "the interpreter Beatrina manages");
79
+ const explicit = readEnv(env, "PYTHON");
80
+ if (explicit) add(explicit, envSetName(env, "PYTHON"));
81
+ if (env.HOME) {
82
+ for (const m of MANAGED_PYTHON_DIRS) add(venvInterpreter(path.join(env.HOME, ...m.dir), platform), m.why);
83
+ }
84
+ if (root) {
85
+ // The task's spelling is `.venv/bin/python`: a venv links python, python3
86
+ // and python3.X to the same file, so `python` is the name that is always there.
87
+ for (const d of PROJECT_VENV_DIRS) add(path.join(root, d, "bin", "python"), `the project's virtualenv (${path.join(root, d)})`);
88
+ }
57
89
  if (env.VIRTUAL_ENV) add(path.join(env.VIRTUAL_ENV, "bin", binName), "the active virtualenv (VIRTUAL_ENV)");
58
90
  if (env.CONDA_PREFIX) add(path.join(env.CONDA_PREFIX, "bin", binName), "the active conda environment (CONDA_PREFIX)");
59
91
  const pyenvRoot = env.PYENV_ROOT || (env.HOME ? path.join(env.HOME, ".pyenv") : "");
@@ -74,29 +106,36 @@ export function pythonCandidates(env = process.env, platform = process.platform,
74
106
  }
75
107
 
76
108
  /**
77
- * The Windows ladder. A venv keeps its interpreter in `Scripts\python.exe`,
78
- * conda at the prefix root, python.org's installer under
79
- * `%LOCALAPPDATA%\Programs\Python\Python3xx`; `python3.exe` on PATH is often
80
- * the Microsoft Store STUB, which opens the Store instead of running, so the
81
- * real installs come before PATH and the stub is skipped by name.
109
+ * The Windows ladder. A venv keeps its interpreter in `Scripts\python.exe` —
110
+ * the managed `%USERPROFILE%\.beatrina\python` and a project's `.venv` are
111
+ * venvs, so that is where they are looked for — conda at the prefix root, python.org's
112
+ * installer under `%LOCALAPPDATA%\Programs\Python\Python3xx`; `python3.exe` on
113
+ * PATH is often the Microsoft Store STUB, which opens the Store instead of
114
+ * running, so the real installs come before PATH and the stub is skipped by name.
82
115
  */
83
- export function windowsPythonCandidates(env = process.env, io = null) {
116
+ export function windowsPythonCandidates(env = process.env, io = null, { root = "" } = {}) {
84
117
  const w = path.win32;
85
118
  const readdir = io ? io.readdir : (p) => { try { return fs.readdirSync(p); } catch { return []; } };
86
119
  const rungs = [];
87
120
  const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
88
- if (env.CARMAR_PYTHON) add(env.CARMAR_PYTHON, "CARMAR_PYTHON");
89
- if (env.USERPROFILE) add(w.join(env.USERPROFILE, ".carmar", "python", "python.exe"), "the interpreter Beatrina manages");
121
+ const explicit = readEnv(env, "PYTHON");
122
+ if (explicit) add(explicit, envSetName(env, "PYTHON"));
123
+ if (env.USERPROFILE) {
124
+ add(w.join(env.USERPROFILE, ".beatrina", "python", "Scripts", "python.exe"), MANAGED_PYTHON_DIRS[0].why);
125
+ }
126
+ if (root) {
127
+ for (const d of PROJECT_VENV_DIRS) add(w.join(root, d, "Scripts", "python.exe"), `the project's virtualenv (${w.join(root, d)})`);
128
+ }
90
129
  if (env.VIRTUAL_ENV) add(w.join(env.VIRTUAL_ENV, "Scripts", "python.exe"), "the active virtualenv (VIRTUAL_ENV)");
91
130
  if (env.CONDA_PREFIX) add(w.join(env.CONDA_PREFIX, "python.exe"), "the active conda environment (CONDA_PREFIX)");
92
131
  const versionOf = (d) => Number((String(d).match(/Python(\d+)/) || [0, 0])[1]);
93
- for (const [root, why] of [
132
+ for (const [root_, why] of [
94
133
  [env.LOCALAPPDATA ? w.join(env.LOCALAPPDATA, "Programs", "Python") : "", "python.org installer (per user)"],
95
134
  [env.ProgramFiles || "", "python.org installer (all users)"],
96
135
  ]) {
97
- if (!root) continue;
98
- const dirs = readdir(root).filter((d) => /^Python3\d+/.test(d)).sort((a, b) => versionOf(b) - versionOf(a));
99
- for (const d of dirs) add(w.join(root, d, "python.exe"), `${why} (${d})`);
136
+ if (!root_) continue;
137
+ const dirs = readdir(root_).filter((d) => /^Python3\d+/.test(d)).sort((a, b) => versionOf(b) - versionOf(a));
138
+ for (const d of dirs) add(w.join(root_, d, "python.exe"), `${why} (${d})`);
100
139
  }
101
140
  for (const dir of String(env.PATH || env.Path || "").split(";")) {
102
141
  if (!dir || /\\WindowsApps\\?$/i.test(dir)) continue; // the Store stub
@@ -133,13 +172,18 @@ function probe(bin) {
133
172
  * reason it was considered and what it said — a user with three Pythons and
134
173
  * ipykernel in the wrong one can see that from the row.
135
174
  *
175
+ * @param {Record<string,string|undefined>} env
176
+ * @param {{root?: string}} [opts] `root`: the session's document directory,
177
+ * whose `.venv`/`venv` is the project rung. Defaults to the working
178
+ * directory, which is the project when `beatrina` is run from one; the host
179
+ * passes the open document's directory when it knows it.
136
180
  * @returns {Promise<{available: boolean, path: string, version: string, why: string,
137
181
  * detail: string, searched: Array<object>}>}
138
182
  */
139
- export async function detectPython(env = process.env) {
183
+ export async function detectPython(env = process.env, { root = process.cwd() } = {}) {
140
184
  const searched = [];
141
185
  let firstWorking = null;
142
- for (const rung of pythonCandidates(env)) {
186
+ for (const rung of pythonCandidates(env, process.platform, null, { root })) {
143
187
  if (!exists(rung.path)) continue;
144
188
  const info = await probe(rung.path);
145
189
  if (!info) {
@@ -161,10 +205,15 @@ export async function detectPython(env = process.env) {
161
205
  return { available: true, ...firstWorking, detail: `Python ${firstWorking.version}`, searched };
162
206
  }
163
207
  const found = searched.filter((s) => s.version);
208
+ // The two phrases "but ipykernel is not" and "No Python interpreter" are
209
+ // read by tools/run-test-suite.mjs (--require-python): a suite that skips
210
+ // with this detail is a Python skip. Change them there too.
164
211
  const detail = found.length
165
212
  ? `Python is installed (${found.map((s) => `${s.version} at ${s.path}`).join("; ")}) but ipykernel is not. `
166
- + "Install it with `python3 -m pip install ipykernel`, or point CARMAR_PYTHON at an interpreter that has it."
167
- : "No Python interpreter was found. Set CARMAR_PYTHON to one, or install Python 3.";
213
+ + "Run `beatrina python setup` to create an interpreter Beatrina manages, install it with "
214
+ + "`python3 -m pip install ipykernel`, or point BEATRINA_PYTHON at an interpreter that has it."
215
+ : "No Python interpreter was found. Run `beatrina python setup` after installing Python 3 from "
216
+ + "https://www.python.org/downloads/, or set BEATRINA_PYTHON to an interpreter that has ipykernel.";
168
217
  return { available: false, path: "", version: "", why: "", detail, searched };
169
218
  }
170
219
 
@@ -182,7 +231,7 @@ export class PythonEngine extends StdioEngine {
182
231
  super({ language: "python", workerPath, env, cwd, platform });
183
232
  if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
184
233
  if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`PythonEngine: no worker at ${workerPath}`);
185
- if (!python) throw new Error("PythonEngine: no Python found (set CARMAR_PYTHON)");
234
+ if (!python) throw new Error("PythonEngine: no Python found (run `beatrina python setup`, or set BEATRINA_PYTHON)");
186
235
  this.python = python;
187
236
  this.kernelName = kernelName;
188
237
  }
@@ -212,7 +261,7 @@ export class PythonEngine extends StdioEngine {
212
261
  return {
213
262
  bin: this.python,
214
263
  args: [path.resolve(this.workerPath)],
215
- env: { CARMAR_WORKER_MODE: "interactive", CARMAR_PYTHON_KERNEL: this.kernelName },
264
+ env: { BEATRINA_WORKER_MODE: "interactive", BEATRINA_PYTHON_KERNEL: this.kernelName },
216
265
  };
217
266
  }
218
267
 
package/host/engine-r.mjs CHANGED
@@ -17,27 +17,21 @@
17
17
  // here: finding an Rscript, the interactive `R` binary beside it, the batch
18
18
  // fallback, the `sys.source` boot line, the R_* environment strip, and the two
19
19
  // capabilities (R has a readline AND a debugger; Python has only the first).
20
- // `LineFramer`, `workerEnvironment` and `MAX_CONSOLE_LINE` are re-exported so
21
- // nothing that imported them from here has to move.
20
+ // The re-exports this file used to carry for `LineFramer`, `MAX_CONSOLE_LINE`
21
+ // and `workerEnvironment` are gone: the migration they eased finished, nothing
22
+ // imported them from here, and the two suites that want the framer take it from
23
+ // host/engine-stdio.mjs, its real home.
22
24
 
23
25
  import fs from "node:fs";
24
26
  import path from "node:path";
25
27
  import { fileURLToPath } from "node:url";
26
- import { LineFramer, MAX_CONSOLE_LINE, StdioEngine, engineEnvironment } from "./engine-stdio.mjs";
28
+ import { StdioEngine } from "./engine-stdio.mjs";
29
+ import { mirrorEnv, readEnv } from "./env-names.mjs";
27
30
  import { RTERM_INTERACTIVE_ARGS, detectRterm, detectWindowsRscript, findInterruptTool } from "./windows-runtime.mjs";
28
31
 
29
- export { LineFramer, MAX_CONSOLE_LINE };
30
-
31
32
  /** The R_* variables a user's shell may carry that would aim R elsewhere. */
32
33
  const STRIP_R_ENV = /^R_(HOME|LIBS|LIBS_USER|LIBS_SITE|PROFILE|ENVIRON|DOC_DIR|INCLUDE_DIR|SHARE_DIR)$/;
33
34
 
34
- /** Kept for callers that built a worker environment by hand. */
35
- export function workerEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env }) {
36
- return engineEnvironment({
37
- sentinel, cmdtag, workerDir, base, strip: STRIP_R_ENV,
38
- extra: { CARMAR_WORKER_MODE: "interactive", ...extra },
39
- });
40
- }
41
35
 
42
36
  const KNOWN_RSCRIPT = [
43
37
  "/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
@@ -48,14 +42,18 @@ const KNOWN_RSCRIPT = [
48
42
  ];
49
43
 
50
44
  /**
51
- * Which R should host the session? An explicit `CARMAR_RSCRIPT`, then the
45
+ * Which R should host the session? An explicit `BEATRINA_RSCRIPT`
46
+ * (beatrina-names: keep-start — or the old `CARMAR_RSCRIPT`, named here on
47
+ * purpose: the rename rewrote the legacy spelling into the current one and
48
+ * left this sentence offering a variable as its own alternative.
49
+ * beatrina-names: keep-end), then the
52
50
  * macOS framework R, then PATH — PATH last on purpose: a Finder-launched
53
51
  * process has `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, rig,
54
52
  * conda and Posit entirely.
55
53
  * @param {string} [explicit]
56
54
  * @returns {string} path to an Rscript binary, or ""
57
55
  */
58
- export function detectRscript(explicit = process.env.CARMAR_RSCRIPT || "", platform = process.platform) {
56
+ export function detectRscript(explicit = readEnv(process.env, "RSCRIPT"), platform = process.platform) {
59
57
  if (explicit && fs.existsSync(explicit)) return explicit;
60
58
  // Windows R is not on PATH after a default install; its ladder is its own.
61
59
  if (platform === "win32") return detectWindowsRscript(process.env);
@@ -85,7 +83,7 @@ export function detectRBinary(rscript) {
85
83
  /** readline's screen width for the interactive worker (see spawnPlan). */
86
84
  export const READLINE_COLUMNS = 100000;
87
85
  /** First on the boot line: give user code back the COLUMNS it started with (or none). */
88
- export const R_WIDTH_BOOT = 'local({ cols <- Sys.getenv("CARMAR_USER_COLUMNS"); Sys.unsetenv("CARMAR_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") }); ';
86
+ export const R_WIDTH_BOOT = 'local({ cols <- Sys.getenv("BEATRINA_USER_COLUMNS"); Sys.unsetenv("BEATRINA_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") }); ';
89
87
 
90
88
  export class REngine extends StdioEngine {
91
89
  /**
@@ -108,15 +106,15 @@ export class REngine extends StdioEngine {
108
106
  // binary has no module path to derive one from (host/main.mjs, moduleDir).
109
107
  this.handoffPath = handoffScript;
110
108
  if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`REngine: no worker at ${workerPath}`);
111
- if (!rscript) throw new Error("REngine: no Rscript found (set CARMAR_RSCRIPT)");
109
+ if (!rscript) throw new Error("REngine: no Rscript found (set BEATRINA_RSCRIPT)");
112
110
  this.rscript = rscript;
113
111
  this.userColumns = processEnv.COLUMNS == null ? "" : String(processEnv.COLUMNS);
114
112
  // Windows: Rterm.exe --ess is the interactive worker (host/windows-runtime.mjs).
115
- // CARMAR_WIN_BATCH=1 keeps the old batch worker, as an escape hatch should
113
+ // BEATRINA_WIN_BATCH=1 keeps the old batch worker, as an escape hatch should
116
114
  // some R build refuse --ess with a piped stdin.
117
115
  if (platform === "win32") {
118
116
  const winIo = io || undefined;
119
- this.rBinary = processEnv.CARMAR_WIN_BATCH === "1" ? "" : detectRterm(rscript, winIo);
117
+ this.rBinary = readEnv(processEnv, "WIN_BATCH") === "1" ? "" : detectRterm(rscript, winIo);
120
118
  this.interruptTool = findInterruptTool({ env: processEnv, rscript, io: winIo });
121
119
  } else {
122
120
  this.rBinary = detectRBinary(rscript);
@@ -136,7 +134,7 @@ export class REngine extends StdioEngine {
136
134
 
137
135
  spawnPlan() {
138
136
  if (this.mode === "interactive" && this.platform === "win32") {
139
- return { bin: this.rBinary, args: [...RTERM_INTERACTIVE_ARGS], env: { CARMAR_WORKER_MODE: "interactive" } };
137
+ return { bin: this.rBinary, args: [...RTERM_INTERACTIVE_ARGS], env: mirrorEnv({ BEATRINA_WORKER_MODE: "interactive" }) };
140
138
  }
141
139
  if (this.mode === "interactive") {
142
140
  return {
@@ -156,13 +154,14 @@ export class REngine extends StdioEngine {
156
154
  // and a Stop at an idle prompt left the stream in error — test:aicoding
157
155
  // and test:sessionrecovery hung. test/r-echo.test.mjs pins both.
158
156
  args: ["--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
159
- env: { CARMAR_WORKER_MODE: "interactive", COLUMNS: String(READLINE_COLUMNS), CARMAR_USER_COLUMNS: this.userColumns },
157
+ // Both spellings on purpose (mirrorEnv): R_WIDTH_BOOT reads BEATRINA_USER_COLUMNS.
158
+ env: mirrorEnv({ BEATRINA_WORKER_MODE: "interactive", COLUMNS: String(READLINE_COLUMNS), BEATRINA_USER_COLUMNS: this.userColumns }),
160
159
  };
161
160
  }
162
161
  return {
163
162
  bin: this.rscript,
164
163
  args: ["--no-save", "--no-restore", "--no-site-file", this.workerPath, this.sentinel],
165
- env: { CARMAR_WORKER_MODE: "batch" },
164
+ env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }),
166
165
  };
167
166
  }
168
167
 
@@ -40,6 +40,7 @@ import fs from "node:fs";
40
40
  import os from "node:os";
41
41
  import path from "node:path";
42
42
  import { NO_INTERRUPT_TOOL, windowsInterruptPlan, windowsKillPlan } from "./windows-runtime.mjs";
43
+ import { mirrorEnv } from "./env-names.mjs";
43
44
 
44
45
  export const MAX_CONSOLE_LINE = 32000;
45
46
  /** The gap between the group SIGINT and the pid SIGINT: the measured lifetime of the spawned
@@ -58,6 +59,11 @@ export const token = () => {
58
59
  * The child's environment: a UTF-8 LC_CTYPE guaranteed, a dumb terminal, and
59
60
  * the worker's two tokens. `strip` removes variables a user's shell may carry
60
61
  * that would aim the child at a different install of its own runtime.
62
+ *
63
+ * Every `BEATRINA_*` variable goes out under its `CARMAR_*` spelling as well
64
+ * (host/env-names.mjs mirrorEnv): spike/worker.R, job-run.R and analyze.R are
65
+ * upstream code that reads the old names, and a user's own Python or Node may
66
+ * be older than this host.
61
67
  */
62
68
  export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, base = process.env, strip = null }) {
63
69
  const env = {};
@@ -75,10 +81,10 @@ export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, bas
75
81
  // TERM=dumb: readline's terminal probe writes an escape sequence to stdout
76
82
  // before anything else, and a dumb terminal keeps it out of the stream.
77
83
  env.TERM = "dumb";
78
- env.CARMAR_SENTINEL = sentinel;
79
- env.CARMAR_CMD_TAG = cmdtag;
80
- env.CARMAR_WORKER_DIR = workerDir;
81
- return env;
84
+ env.BEATRINA_SENTINEL = sentinel;
85
+ env.BEATRINA_CMD_TAG = cmdtag;
86
+ env.BEATRINA_WORKER_DIR = workerDir;
87
+ return mirrorEnv(env);
82
88
  }
83
89
 
84
90
  /**
@@ -181,8 +187,8 @@ export class StdioEngine extends EventEmitter {
181
187
  }
182
188
 
183
189
  /**
184
- * Does the worker read a stop flag between top-level expressions (CARMAR_STOP_DIR)? R's does
185
- * (spike/worker.R `carmar_stop_requested`), because a signal cannot always reach R: libc's
190
+ * Does the worker read a stop flag between top-level expressions (BEATRINA_STOP_DIR)? R's does
191
+ * (spike/worker.R `beatrina_stop_requested`), because a signal cannot always reach R: libc's
186
192
  * system() ignores SIGINT in R while its child runs, and whether the signal the host sends after
187
193
  * the child dies lands before or after R walks on to the next line is a race — on Linux it lost
188
194
  * every time (`beatrina check` on .39, 2026-09-16: 0/3 Stops of system("sleep 60")). The flag is
@@ -229,7 +235,7 @@ export class StdioEngine extends EventEmitter {
229
235
  }
230
236
  const env = engineEnvironment({
231
237
  sentinel: this.sentinel, cmdtag: this.cmdtag, workerDir,
232
- extra: { ...this.extraEnv, ...(plan.env || {}), ...(this.stopDir ? { CARMAR_STOP_DIR: this.stopDir } : {}) }, strip: this.stripEnv,
238
+ extra: { ...this.extraEnv, ...(plan.env || {}), ...(this.stopDir ? { BEATRINA_STOP_DIR: this.stopDir } : {}) }, strip: this.stripEnv,
233
239
  });
234
240
  // `detached`: the worker leads its OWN process group, so an interrupt can
235
241
  // be sent to the group and reach a child under system() too — a terminal's
@@ -253,8 +259,14 @@ export class StdioEngine extends EventEmitter {
253
259
  this.proc.stdout.on("data", (d) => out.feed(d).forEach((e) => this.emit("event", e)));
254
260
  this.proc.stderr.on("data", (d) => err.feed(d).forEach((e) => this.emit("event", e)));
255
261
  this.proc.stdin.on("error", () => { /* a dead worker's pipe: the exit event reports it */ });
256
- this.proc.on("error", (e) => { this.alive = false; this.emit("spawn-error", e); });
257
- this.proc.on("exit", (code, signal) => {
262
+ // A failed spawn emits `error` and `close`, but no `exit`. Give the plane
263
+ // the same terminal event and cleanup in either case, exactly once.
264
+ let finished = false;
265
+ let spawned = false;
266
+ this.proc.once("spawn", () => { spawned = true; });
267
+ const finish = (code, signal) => {
268
+ if (finished) return;
269
+ finished = true;
258
270
  this.alive = false;
259
271
  out.feed("", { flush: true }).forEach((e) => this.emit("event", e));
260
272
  err.feed("", { flush: true }).forEach((e) => this.emit("event", e));
@@ -262,7 +274,20 @@ export class StdioEngine extends EventEmitter {
262
274
  this.spilled.clear();
263
275
  if (this.stopDir) { try { fs.rmSync(this.stopDir, { recursive: true, force: true }); } catch { /* gone */ } this.stopDir = null; }
264
276
  this.emit("exit", { code, signal });
277
+ };
278
+ this.proc.on("error", (e) => {
279
+ if (finished) return;
280
+ // ChildProcess also emits error when kill/send fails after a successful
281
+ // spawn. That says nothing about whether the worker has exited.
282
+ if (spawned) {
283
+ this.emit("event", { type: "stderr", text: `${this.label} process control failed: ${e.message}` });
284
+ return;
285
+ }
286
+ this.alive = false;
287
+ this.emit("spawn-error", e);
288
+ finish(null, null);
265
289
  });
290
+ this.proc.on("exit", finish);
266
291
  this.afterSpawn();
267
292
  return Promise.resolve(this);
268
293
  }
@@ -288,7 +313,7 @@ export class StdioEngine extends EventEmitter {
288
313
  if (cmd.type === "exec") this.interruptRefusalSaid = false;
289
314
  let json = JSON.stringify(cmd);
290
315
  if (Buffer.byteLength(json) > MAX_CONSOLE_LINE) {
291
- const spill = path.join(os.tmpdir(), `carmar-cmd-${crypto.randomBytes(8).toString("hex")}.json`);
316
+ const spill = path.join(os.tmpdir(), `beatrina-cmd-${crypto.randomBytes(8).toString("hex")}.json`);
292
317
  fs.writeFileSync(spill, json, { mode: 0o600 });
293
318
  this.spilled.add(spill);
294
319
  json = JSON.stringify({ type: "cmdfile", path: spill });
@@ -0,0 +1,148 @@
1
+ // env-names.mjs — one place that knows the product's environment-variable names.
2
+ // beatrina-names: keep-file — this module IS the reader of the older spelling.
3
+ //
4
+ // The product is Beatrina. Its environment variables are `BEATRINA_<NAME>`.
5
+ // Every one of them was `CARMAR_<NAME>` until 2026-09-16, and that spelling is
6
+ // still ACCEPTED — launchers, CI, the R package and users' shells set it — so
7
+ // reading a variable means asking for both, new name first. Nothing in the
8
+ // host reads `process.env.CARMAR_…` or `process.env.BEATRINA_…` directly;
9
+ // it calls `readEnv(process.env, "PORT")` and gets whichever is set.
10
+ //
11
+ // Children (engines, jobs, the terminal, a spawned kernel under test) may be
12
+ // older code that knows only the old name, so an environment handed to a child
13
+ // is passed through `mirrorEnv()`, which sets each name under both prefixes.
14
+ // When the last reader of `CARMAR_` is gone, `LEGACY_PREFIX` goes with it and
15
+ // `mirrorEnv` becomes the identity.
16
+
17
+ export const PREFIX = "BEATRINA_";
18
+ export const LEGACY_PREFIX = "CARMAR_";
19
+
20
+ /** The canonical variable name for a suffix: envName("PORT") → "BEATRINA_PORT". */
21
+ export function envName(suffix) {
22
+ return PREFIX + suffix;
23
+ }
24
+
25
+ /** The names a reader accepts for a suffix, canonical first. */
26
+ export function envNames(suffix) {
27
+ return [PREFIX + suffix, LEGACY_PREFIX + suffix];
28
+ }
29
+
30
+ /**
31
+ * Read a variable under either prefix, canonical first.
32
+ * @param {Record<string, string|undefined>} source usually process.env
33
+ * @param {string} suffix the name without its prefix, e.g. "RUNTIME_DIR"
34
+ * @param {string} unset what an absent variable reads as
35
+ * @returns {string}
36
+ */
37
+ export function readEnv(source, suffix, unset = "") {
38
+ for (const name of envNames(suffix)) {
39
+ const v = valueOf(source, name);
40
+ if (v != null) return String(v);
41
+ }
42
+ return unset;
43
+ }
44
+
45
+ /** True when the variable is set under either prefix (even to ""). */
46
+ export function hasEnv(source, suffix) {
47
+ return envNames(suffix).some((name) => valueOf(source, name) != null);
48
+ }
49
+
50
+ /**
51
+ * The spelling that is actually set, for a message that names a switch:
52
+ * `envSetName(env, "NO_TERMINAL")` is "CARMAR_NO_TERMINAL" on a launcher that
53
+ * still sets the old name and "BEATRINA_NO_TERMINAL" otherwise (also when
54
+ * neither is set — the canonical name is the one to document).
55
+ */
56
+ export function envSetName(source, suffix) {
57
+ return envNames(suffix).find((name) => valueOf(source, name) != null) || envName(suffix);
58
+ }
59
+
60
+ /**
61
+ * Read a FULL variable name the way the host's `Sys.getenv`-style getters do:
62
+ * a name under either prefix is read under both (canonical first); any other
63
+ * name (`PATH`, `BEATRIX_APP_ID`, `ProgramData`) is read as it is. This is
64
+ * what lets every `env("CARMAR_PORT")` call site keep its spelling while a
65
+ * launcher that sets `BEATRINA_PORT` is honoured.
66
+ */
67
+ export function readEnvName(source, name, unset = "") {
68
+ const suffix = suffixOf(name);
69
+ if (suffix != null) return readEnv(source, suffix, unset);
70
+ const v = valueOf(source, name);
71
+ return v == null ? unset : String(v);
72
+ }
73
+
74
+ /**
75
+ * A `(name, unset = "") => string` getter over `source`, dual-read (readEnvName).
76
+ * It carries `source` so that a helper handed the GETTER (the planes get one)
77
+ * can still see which spelling is really set: asked for `BEATRINA_X`, the
78
+ * getter answers from `CARMAR_X` too, which is right for a read and wrong for
79
+ * `envSetName`.
80
+ */
81
+ export function envReader(source = process.env) {
82
+ const reader = (name, unset = "") => readEnvName(source, name, unset);
83
+ reader.source = source;
84
+ return reader;
85
+ }
86
+
87
+ /** Set a variable under BOTH prefixes on `target` (an environment object). */
88
+ export function setEnv(target, suffix, value) {
89
+ for (const name of envNames(suffix)) target[name] = String(value);
90
+ return target;
91
+ }
92
+
93
+ /** Set a FULL name on `target`; a prefixed name is set under both prefixes. */
94
+ export function setEnvName(target, name, value) {
95
+ const suffix = suffixOf(name);
96
+ if (suffix != null) return setEnv(target, suffix, value);
97
+ target[name] = String(value);
98
+ return target;
99
+ }
100
+
101
+ /** Remove a variable under BOTH prefixes from `target`. */
102
+ export function unsetEnv(target, suffix) {
103
+ for (const name of envNames(suffix)) delete target[name];
104
+ return target;
105
+ }
106
+
107
+ /** The suffix of a name under either prefix, or null for any other name. */
108
+ export function suffixOf(name) {
109
+ const n = String(name);
110
+ if (n.startsWith(PREFIX)) return n.slice(PREFIX.length);
111
+ if (n.startsWith(LEGACY_PREFIX)) return n.slice(LEGACY_PREFIX.length);
112
+ return null;
113
+ }
114
+
115
+ // A source is an environment OBJECT (process.env, a child's env) or a
116
+ // `Sys.getenv`-style GETTER (what the planes are handed); a getter answers ""
117
+ // for an unset name, so "" from a getter is read as unset.
118
+ function valueOf(source, name) {
119
+ if (!source) return undefined;
120
+ if (typeof source === "function" && source.source) return valueOf(source.source, name);
121
+ if (typeof source === "function") {
122
+ const v = source(name, undefined);
123
+ return v == null || v === "" ? undefined : v;
124
+ }
125
+ return source[name];
126
+ }
127
+
128
+ /**
129
+ * A copy of `source` in which every `BEATRINA_X` is also present as `CARMAR_X`
130
+ * and vice versa, the canonical spelling winning when both are set and differ.
131
+ * For environments handed to child processes.
132
+ * @param {Record<string, string|undefined>} source
133
+ * @returns {Record<string, string>}
134
+ */
135
+ export function mirrorEnv(source) {
136
+ const out = {};
137
+ for (const [k, v] of Object.entries(source || {})) if (v != null) out[k] = String(v);
138
+ for (const [k, v] of Object.entries(source || {})) {
139
+ if (v == null) continue;
140
+ if (k.startsWith(PREFIX)) out[LEGACY_PREFIX + k.slice(PREFIX.length)] = String(v);
141
+ }
142
+ for (const [k, v] of Object.entries(source || {})) {
143
+ if (v == null || !k.startsWith(LEGACY_PREFIX)) continue;
144
+ const canonical = PREFIX + k.slice(LEGACY_PREFIX.length);
145
+ if (out[canonical] == null) out[canonical] = String(v);
146
+ }
147
+ return out;
148
+ }
@@ -0,0 +1,51 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+
4
+ export const GATEWAY_TOKEN_HEADER = "x-beatrina-gateway-token";
5
+ export const GATEWAY_MANAGED_COMMANDS = Object.freeze(["session-new", "session_upgrade", "session_restart", "update_action"]);
6
+ export const GATEWAY_MANAGED_PATHS = Object.freeze(["/session/upgrade", "/session/restart", "/shutdown", "/pair", "/pair/approve", "/published/authorize"]);
7
+ export const GATEWAY_HIDDEN_CAPABILITIES = Object.freeze(["session-restart-v1", "published-direct-v1", "published-pairing-v3", "signed-updates-v1"]);
8
+
9
+ /** Gateway sessions are launched and upgraded exclusively by their broker. */
10
+ export function gatewayCommands(commands, enabled) {
11
+ return enabled ? commands.filter((command) => !GATEWAY_MANAGED_COMMANDS.includes(command)) : commands;
12
+ }
13
+
14
+ /** Validate a gateway credential without ever including its value in errors. */
15
+ export function validateGatewayToken(token) {
16
+ if (typeof token !== "string" || !/^(?:[a-fA-F0-9]{2}){32,256}$/.test(token)) {
17
+ throw new Error("Gateway token must contain 32–256 bytes encoded as hexadecimal.");
18
+ }
19
+ return token;
20
+ }
21
+
22
+ /** Read the gateway-only credential from a private regular file at startup. */
23
+ export function loadGatewayToken(file) {
24
+ if (file === undefined || file === null) return undefined;
25
+ if (typeof file !== "string" || !file.trim()) throw new Error("Gateway token file path is empty.");
26
+ let fd;
27
+ try {
28
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | fs.constants.O_NONBLOCK);
29
+ const stat = fs.fstatSync(fd);
30
+ if (!stat.isFile() || stat.size > 1024 || (process.platform !== "win32" && (stat.mode & 0o077))) {
31
+ throw new Error("unsafe gateway token file");
32
+ }
33
+ return validateGatewayToken(fs.readFileSync(fd, "utf8").trim());
34
+ } catch {
35
+ throw new Error("Cannot load gateway token: use a private regular file containing 32–256 bytes encoded as hexadecimal.");
36
+ } finally {
37
+ if (fd !== undefined) fs.closeSync(fd);
38
+ }
39
+ }
40
+
41
+ /** Build a constant-time credential check; only absent configuration disables it. */
42
+ export function gatewayTokenGate(token) {
43
+ if (token === undefined) return () => true;
44
+ const expected = crypto.createHash("sha256").update(validateGatewayToken(token)).digest();
45
+ return (req) => {
46
+ const supplied = req.headers[GATEWAY_TOKEN_HEADER];
47
+ if (typeof supplied !== "string" || supplied.length > 512) return false;
48
+ const actual = crypto.createHash("sha256").update(supplied).digest();
49
+ return crypto.timingSafeEqual(expected, actual);
50
+ };
51
+ }