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