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
@@ -4,11 +4,12 @@
4
4
  // can name. It speaks the wire worker.R and worker.py speak, so everything but
5
5
  // three answers is StdioEngine's:
6
6
  //
7
- // · WHICH NODE. The host's own `process.execPath` when the host is a plain
8
- // `node host/main.mjs` — the Node the R launcher already found and
9
- // version-checked. A packaged single-executable host (node:sea) cannot run
10
- // a script, so there the engine needs `BEATRIX_NODE` / `CARMAR_NODE`, and
11
- // says so on the ready frame rather than appearing and failing.
7
+ // · WHICH NODE. The host's own `process.execPath` — the Node the R launcher
8
+ // already found and version-checked — or `BEATRIX_NODE`/`BEATRINA_NODE`
9
+ // when one is named. There is no self-hosted rung: the node:sea build that
10
+ // needed one is retired (f5979e5, 2026-09-16), and every distribution now
11
+ // stages `engines/js/` beside the host, so the worker is a script on disk
12
+ // in all three of them.
12
13
  // · A CONSOLE LINE MEANS NOTHING. The worker reads commands only; there is
13
14
  // no prompt, so canInput and canDebug are false and the plane refuses
14
15
  // input_reply and debug_cmd for this engine by those capabilities.
@@ -18,6 +19,7 @@
18
19
 
19
20
  import fs from "node:fs";
20
21
  import { StdioEngine } from "./engine-stdio.mjs";
22
+ import { mirrorEnv, readEnv } from "./env-names.mjs";
21
23
  import { findInterruptTool } from "./windows-runtime.mjs";
22
24
 
23
25
  export const JS_MIN_NODE = 22;
@@ -26,17 +28,12 @@ export const JS_MIN_NODE = 22;
26
28
  * Find a Node for the JavaScript engine, and SAY WHAT WAS DECIDED.
27
29
  * @returns {{available: boolean, path: string, version: string, detail: string}}
28
30
  */
29
- export function detectNode({ env = process.env, packaged = false, execPath = process.execPath, versions = process.versions } = {}) {
30
- const explicit = env.BEATRIX_NODE || env.CARMAR_NODE || "";
31
+ export function detectNode({ env = process.env, execPath = process.execPath, versions = process.versions } = {}) {
32
+ const explicit = env.BEATRIX_NODE || readEnv(env, "NODE");
31
33
  if (explicit) {
32
34
  if (!fs.existsSync(explicit)) return { available: false, path: "", version: "", detail: `BEATRIX_NODE points at ${explicit}, which does not exist.` };
33
35
  return { available: true, path: explicit, version: "", detail: `Node at ${explicit} (BEATRIX_NODE)` };
34
36
  }
35
- if (packaged) {
36
- // A single executable IS a Node: it runs the worker bundled inside it when
37
- // started with BEATRIX_ENGINE_ROLE=js (the retired executable build; kept for any old binary).
38
- return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node} (inside this executable)`, selfHosted: true };
39
- }
40
37
  const major = Number(String(versions.node || "").split(".")[0]);
41
38
  if (!(major >= JS_MIN_NODE)) return { available: false, path: "", version: versions.node || "", detail: `JavaScript chunks need Node ${JS_MIN_NODE} or newer; this host runs on ${versions.node}.` };
42
39
  return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node}` };
@@ -51,10 +48,9 @@ export class JsEngine extends StdioEngine {
51
48
  * @param {Record<string,string>} [opts.env]
52
49
  * @param {string} [opts.cwd]
53
50
  */
54
- constructor({ workerPath, node, env = {}, cwd, platform = process.platform, selfHosted = false } = {}) {
51
+ constructor({ workerPath, node, env = {}, cwd, platform = process.platform } = {}) {
55
52
  super({ language: "js", workerPath, env, cwd, platform });
56
- this.selfHosted = selfHosted;
57
- if (!selfHosted && (!workerPath || !fs.existsSync(workerPath))) throw new Error(`JsEngine: no worker at ${workerPath}`);
53
+ if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`JsEngine: no worker at ${workerPath}`);
58
54
  if (!node) throw new Error("JsEngine: no Node found (set BEATRIX_NODE)");
59
55
  this.node = node;
60
56
  if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
@@ -69,8 +65,7 @@ export class JsEngine extends StdioEngine {
69
65
  // which Node still labels experimental and announces on stderr — into the
70
66
  // first chunk that imports anything. The label is Node's; the output is not
71
67
  // the user's, so it is not printed.
72
- if (this.selfHosted) return { bin: this.node, args: [], env: { CARMAR_WORKER_MODE: "batch", BEATRIX_ENGINE_ROLE: "js" } };
73
- return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: { CARMAR_WORKER_MODE: "batch" } };
68
+ return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }) };
74
69
  }
75
70
 
76
71
  /**
@@ -93,6 +88,10 @@ export class JsEngine extends StdioEngine {
93
88
  return true;
94
89
  }
95
90
 
91
+ /** process.kill, replaceable in tests. */
92
+ get killImpl() { return this._killImpl || process.kill.bind(process); }
93
+ set killImpl(fn) { this._killImpl = fn; }
94
+
96
95
  /** There is no prompt behind this worker: a console line is refused, not typed. */
97
96
  console() { throw new Error("The JavaScript engine has no console prompt."); }
98
97
  }
@@ -77,6 +77,7 @@ export class EnginePool extends EventEmitter {
77
77
  });
78
78
  plane.sockets = () => this.sockets();
79
79
  plane.on("broadcast", (payload) => this.emit("broadcast", payload));
80
+ plane.on("broadcast-pages", (payload) => this.emit("broadcast-pages", payload));
80
81
  this.planes.set(spec.name, plane);
81
82
  }
82
83
  }
@@ -329,7 +330,7 @@ export class EnginePool extends EventEmitter {
329
330
  * With no engine named it is the primary's — which is what "Restart R" in
330
331
  * the page means and what every existing client sends.
331
332
  */
332
- async restart(engine) {
333
+ async restart(engine, opts = {}) {
333
334
  const key = engineKey(engine, this.primaryName);
334
335
  const plane = this.planes.get(key);
335
336
  if (!plane) return undefined;
@@ -338,19 +339,66 @@ export class EnginePool extends EventEmitter {
338
339
  const r = await this.ensure(key);
339
340
  return r.ok ? undefined : undefined;
340
341
  }
341
- return plane.restart();
342
+ return plane.restart(key === this.primaryName ? opts : {});
343
+ }
344
+
345
+ /** "Restart, keep variables" belongs to the primary (R) engine. */
346
+ setResumePending(token) { this.primary?.setResumePending(token); }
347
+ freshResumeReport(now) { return this.primary ? this.primary.freshResumeReport(now) : null; }
348
+ cancelSuspend(rec) { return this.primary ? this.primary.cancelSuspend(rec) : false; }
349
+
350
+ /** Controls name the originating exec in `run`; `id` identifies the reply.
351
+ * Older pages omit `run`, so allow that only when this socket owns exactly
352
+ * one matching prompt. Never choose another page's or another run's engine.
353
+ *
354
+ * THE NARROWING IS IN STAGES, AND THAT IS THE POINT. The routing rule is one
355
+ * predicate — waiting, mine, the run named — but written as one `filter` it
356
+ * can only ever answer "no", and every refusal read alike: a debugger command
357
+ * sent when nothing is paused, an answer to a question another window is
358
+ * holding, and a reply naming a run that has already finished all came back
359
+ * as "This page has no matching run waiting for this control." Those are
360
+ * three different situations for the person reading the message and three
361
+ * different bugs for whoever is reading the log, so the list is narrowed a
362
+ * stage at a time and whichever stage empties it names the reason (S3,
363
+ * 2026-09-18). The routing itself is unchanged — the stages compose to the
364
+ * same predicate, and `test/engine-pool-controls.test.mjs` pins that across
365
+ * all seven ownership scenarios.
366
+ */
367
+ controlPlane(cmd, rec, state, type) {
368
+ const noun = type === "debug_cmd" ? "a debugger command" : "an answer";
369
+ // Stage 0: every engine actually waiting for this control, whoever it
370
+ // belongs to. The route is carried along because each later stage asks it
371
+ // a different question.
372
+ const waiting = [...this.planes.values()]
373
+ .map((plane) => ({ plane, route: plane.engine && plane[state] ? plane.routes.get(plane.active) : null }))
374
+ .filter((c) => c.route?.kind === "exec");
375
+ // Stage 1: the run this control names, if it names one. A queued run is
376
+ // correctly absent here — it is not waiting, it has not started.
377
+ const named = scalarChr(cmd.run) ? cmd.run : "";
378
+ const forRun = named ? waiting.filter((c) => c.route.clientId === named) : waiting;
379
+ // Stage 2: of those, the ones this socket started.
380
+ const candidates = forRun.filter((c) => c.route.rec === rec);
381
+ if (candidates.length === 1) return candidates[0].plane;
382
+
383
+ const error = candidates.length > 1
384
+ ? "More than one run is waiting; name the originating run in this control."
385
+ : forRun.length > 0
386
+ // It IS waiting — for somebody else. The distinction the reload path
387
+ // depends on: `adopt` is how the other page takes it over.
388
+ ? `That prompt is not this page's — another page owns the run waiting for ${noun}.`
389
+ : waiting.length > 0 && named
390
+ ? `The run named in this control is not waiting for ${noun}.`
391
+ : `This session is not waiting for ${noun}.`;
392
+ rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), error }));
393
+ return null;
342
394
  }
343
395
 
344
396
  inputReply(cmd, rec) {
345
- const waiting = [...this.planes.values()].find((p) => p.engine && p.inputWaiting);
346
- if (waiting) return waiting.inputReply(cmd, rec);
347
- return this.primary ? this.primary.inputReply(cmd, rec) : undefined;
397
+ return this.controlPlane(cmd, rec, "inputWaiting", "input_reply")?.inputReply(cmd, rec);
348
398
  }
349
399
 
350
400
  debugCmd(cmd, rec) {
351
- const paused = [...this.planes.values()].find((p) => p.engine && p.debugPaused);
352
- if (paused) return paused.debugCmd(cmd, rec);
353
- return this.primary ? this.primary.debugCmd(cmd, rec) : undefined;
401
+ return this.controlPlane(cmd, rec, "debugPaused", "debug_cmd")?.debugCmd(cmd, rec);
354
402
  }
355
403
 
356
404
  runstate(cmd, rec) {
@@ -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);
@@ -82,6 +80,11 @@ export function detectRBinary(rscript) {
82
80
  }
83
81
 
84
82
  /** One R worker process. */
83
+ /** readline's screen width for the interactive worker (see spawnPlan). */
84
+ export const READLINE_COLUMNS = 100000;
85
+ /** First on the boot line: give user code back the COLUMNS it started with (or none). */
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") }); ';
87
+
85
88
  export class REngine extends StdioEngine {
86
89
  /**
87
90
  * @param {Object} opts
@@ -103,14 +106,15 @@ export class REngine extends StdioEngine {
103
106
  // binary has no module path to derive one from (host/main.mjs, moduleDir).
104
107
  this.handoffPath = handoffScript;
105
108
  if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`REngine: no worker at ${workerPath}`);
106
- 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)");
107
110
  this.rscript = rscript;
111
+ this.userColumns = processEnv.COLUMNS == null ? "" : String(processEnv.COLUMNS);
108
112
  // Windows: Rterm.exe --ess is the interactive worker (host/windows-runtime.mjs).
109
- // 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
110
114
  // some R build refuse --ess with a piped stdin.
111
115
  if (platform === "win32") {
112
116
  const winIo = io || undefined;
113
- this.rBinary = processEnv.CARMAR_WIN_BATCH === "1" ? "" : detectRterm(rscript, winIo);
117
+ this.rBinary = readEnv(processEnv, "WIN_BATCH") === "1" ? "" : detectRterm(rscript, winIo);
114
118
  this.interruptTool = findInterruptTool({ env: processEnv, rscript, io: winIo });
115
119
  } else {
116
120
  this.rBinary = detectRBinary(rscript);
@@ -130,7 +134,7 @@ export class REngine extends StdioEngine {
130
134
 
131
135
  spawnPlan() {
132
136
  if (this.mode === "interactive" && this.platform === "win32") {
133
- 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" }) };
134
138
  }
135
139
  if (this.mode === "interactive") {
136
140
  return {
@@ -138,24 +142,26 @@ export class REngine extends StdioEngine {
138
142
  // --no-echo suppresses the "> " prompt; the echo it does not suppress
139
143
  // is scrubbed by LineFramer through the cmdtag.
140
144
  //
141
- // --no-readline FIRST. With readline, R echoes every line it reads back
142
- // on stdout — and on Linux (R 4.3.3, 2026-09-16) readline's horizontal
143
- // scrolling redraws a long command line as "<" + its tail, which no
144
- // longer carries the cmdtag, so the echo leaked into chunk output
145
- // (`beatrina check` on .39 read it as "the line after Stop ran").
146
- // R honours --no-readline only BEFORE --interactive: after it, the flag
147
- // is ignored and capabilities("cledit") stays TRUE (measured on Linux
148
- // R 4.3.3 and macOS R 4.5.2). Without readline R reads plain lines,
149
- // echoes nothing, and readline()/browser() still read the console.
150
- // test/r-echo.test.mjs pins both the order and the silence.
151
- args: ["--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
152
- env: { CARMAR_WORKER_MODE: "interactive" },
145
+ // COLUMNS is readline's screen width when there is no terminal. At the
146
+ // default 80, readline's horizontal scrolling redraws a long command
147
+ // line as "<" + its tail — which carries no cmdtag, so on Linux (R 4.3.3,
148
+ // `beatrina check`, 2026-09-16) fragments of the wire command leaked into
149
+ // chunk output. A width no line reaches keeps the echo whole, tag and
150
+ // all, and the scrub drops it. It is readline's width only: the boot
151
+ // line restores the user's COLUMNS before any user code (R_WIDTH_BOOT),
152
+ // and getOption("width") never read it. --no-readline was the first fix
153
+ // and is WRONG: without readline R reads the console with stdio fgets,
154
+ // and a Stop at an idle prompt left the stream in error — test:aicoding
155
+ // and test:sessionrecovery hung. test/r-echo.test.mjs pins both.
156
+ args: ["--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
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 }),
153
159
  };
154
160
  }
155
161
  return {
156
162
  bin: this.rscript,
157
163
  args: ["--no-save", "--no-restore", "--no-site-file", this.workerPath, this.sentinel],
158
- env: { CARMAR_WORKER_MODE: "batch" },
164
+ env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }),
159
165
  };
160
166
  }
161
167
 
@@ -174,7 +180,7 @@ export class REngine extends StdioEngine {
174
180
  const attach = handoff
175
181
  ? `local({ e <- attach(NULL, name = "beatrix:handoff", warn.conflicts = FALSE); sys.source(${JSON.stringify(handoff)}, envir = e, keep.source = FALSE) }); `
176
182
  : "";
177
- this.rawLine(`${attach}sys.source(${JSON.stringify(worker)}, envir = globalenv(), keep.source = FALSE)`);
183
+ this.rawLine(`${R_WIDTH_BOOT}${attach}sys.source(${JSON.stringify(worker)}, envir = globalenv(), keep.source = FALSE)`);
178
184
  }
179
185
 
180
186
  /** engines/r/handoff.R for this host, or "" when this build has none. */