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.
- package/NOTICES +1 -1
- package/README.md +6 -0
- package/beatrina_V0.9.43.html +1885 -0
- package/beatrina_V0.9.43.html.inputs.json +1 -0
- package/bin/beatrina.mjs +156 -26
- package/bin/browser.mjs +31 -0
- package/bin/cli.mjs +64 -5
- package/bin/doctor-rows.mjs +31 -0
- package/bin/failsafe.mjs +8 -3
- package/bin/finder.mjs +74 -0
- package/bin/lsquery.swift +55 -0
- package/bin/pages.mjs +81 -0
- package/bin/python-setup.mjs +207 -0
- package/bin/runtime-dirs.mjs +27 -0
- package/bin/sessions.mjs +30 -10
- package/bin/shortcut.mjs +271 -35
- package/bin/update-check.mjs +2 -2
- package/build-info.json +1 -1
- package/engines/js/worker.mjs +6 -3
- package/engines/python/adapter.py +14 -13
- package/engines/python/analyze.py +4 -2
- package/engines/python/bootstrap.py +32 -31
- package/engines/python/debugger.py +10 -6
- package/engines/python/engine.json +1 -1
- package/engines/python/handoff.py +4 -2
- package/engines/python/worker.py +39 -10
- package/engines/r/engine.json +1 -1
- package/engines/r/handoff.R +4 -3
- package/failsafe/ai-policy.R +16 -16
- package/failsafe/ai-store.R +6 -6
- package/failsafe/cite.R +11 -11
- package/failsafe/journal.R +11 -11
- package/failsafe/plugins.R +27 -27
- package/failsafe/serve.R +364 -239
- package/failsafe/session-documents.R +104 -0
- package/host/ai-authority.mjs +55 -0
- package/host/ai-policy.mjs +48 -26
- package/host/bundle.mjs +194 -0
- package/host/deployment.mjs +25 -18
- package/host/engine-js.mjs +16 -17
- package/host/engine-pool.mjs +56 -8
- package/host/engine-python.mjs +80 -31
- package/host/engine-r.mjs +38 -32
- package/host/engine-stdio.mjs +45 -12
- package/host/env-names.mjs +148 -0
- package/host/gateway-token.mjs +51 -0
- package/host/journal-store.mjs +10 -9
- package/host/main.mjs +216 -76
- package/host/payload.mjs +131 -0
- package/host/planes/README.md +1 -1
- package/host/planes/ai-store.mjs +6 -5
- package/host/planes/ai.mjs +49 -29
- package/host/planes/analyze.mjs +6 -5
- package/host/planes/bundle.mjs +344 -0
- package/host/planes/choose.mjs +404 -0
- package/host/planes/cite.mjs +18 -17
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +43 -20
- package/host/planes/latex.mjs +41 -5
- package/host/planes/mcp.mjs +80 -91
- package/host/planes/pair.mjs +24 -24
- package/host/planes/pipe-term.mjs +2 -2
- package/host/planes/plugins.mjs +1 -1
- package/host/planes/recent-documents.mjs +170 -0
- package/host/planes/sessions.mjs +230 -82
- package/host/planes/settings.mjs +4 -4
- package/host/planes/terminal.mjs +13 -11
- package/host/planes/test-file.mjs +2 -2
- package/host/planes/update.mjs +6 -13
- package/host/plugin-store.mjs +35 -52
- package/host/recent-documents.mjs +124 -0
- package/host/runtime-dir.mjs +47 -0
- package/host/server.mjs +123 -21
- package/host/session-keep.mjs +70 -0
- package/host/settings.mjs +85 -37
- package/host/update-record.mjs +3 -2
- package/host/user-dirs.mjs +60 -18
- package/host/which.mjs +39 -0
- package/host/windows-runtime.mjs +6 -3
- package/host/worker-plane.mjs +80 -7
- package/host/ws.mjs +9 -2
- package/host/zip.mjs +237 -0
- package/kernel/analyze.R +1 -1
- package/{check → kernel/check}/acceptance.mjs +1 -1
- package/kernel/check/knit-file.mjs +19639 -0
- package/{check → kernel/check}/session.mjs +49 -8
- package/kernel/deployment.R +20 -20
- package/kernel/examples/NOTICE.md +1 -1
- package/kernel/fileio.R +6 -6
- package/kernel/index.html +2 -2
- package/kernel/job-run.R +83 -22
- package/kernel/jobs.R +28 -12
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +24 -21
- package/kernel/knitr-run.R +50 -9
- package/kernel/latex.R +429 -30
- package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
- package/kernel/notebook-page.R +7 -7
- package/kernel/project.R +10 -10
- package/kernel/settings.R +105 -56
- package/kernel/sniff.R +4 -4
- package/kernel/typst.R +121 -0
- package/kernel/worker.R +602 -123
- package/kernel/workspace-keep.R +188 -0
- package/lib/agent-authoring-contract.js +28 -19
- package/lib/cell-kinds.js +3 -3
- package/lib/engine-labels.js +4 -4
- package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
- package/package.json +4 -3
- package/carmar_V0.8.6.html +0 -1310
package/host/engine-js.mjs
CHANGED
|
@@ -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`
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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,
|
|
30
|
-
const explicit = env.BEATRIX_NODE || env
|
|
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
|
|
51
|
+
constructor({ workerPath, node, env = {}, cwd, platform = process.platform } = {}) {
|
|
55
52
|
super({ language: "js", workerPath, env, cwd, platform });
|
|
56
|
-
|
|
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
|
-
|
|
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
|
}
|
package/host/engine-pool.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/host/engine-python.mjs
CHANGED
|
@@ -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
|
-
* `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
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
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
|
|
89
|
-
if (
|
|
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 [
|
|
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 (!
|
|
98
|
-
const dirs = readdir(
|
|
99
|
-
for (const d of dirs) add(w.join(
|
|
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
|
-
+ "
|
|
167
|
-
|
|
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
|
|
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: {
|
|
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`, `
|
|
21
|
-
//
|
|
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 {
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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: {
|
|
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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
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: {
|
|
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. */
|