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