beatrina 0.8.7 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
@@ -0,0 +1,55 @@
1
+ // ai-authority.mjs — the supervisor's half of the chat authority contract.
2
+ //
3
+ // The page's half is `lib/ai-authority.js`, and the two lists are ONE list in
4
+ // two files for the reason every other pair in this project is (THEME_TOKENS /
5
+ // EDITOR_TOKENS, LATEX_TEMPLATE_KEYS / TEMPLATE_KEYS): the kernel ships `host/`
6
+ // and `spike/` without `lib/`, and no host module imports across that line.
7
+ // `test/ai-chat-authority.test.mjs` reads both files and fails if they drift.
8
+ //
9
+ // WHY THE HOST IS WHERE THIS IS DECIDED. A CLI turn is a process THIS process
10
+ // spawns. The tool list handed to that process is the only gate with no
11
+ // page-side bypass: there is nothing in the browser to work around, because
12
+ // the spawn was never made with those tools in it. The page's copy exists to
13
+ // ASK for a level and to describe the lane honestly; the host's copy is what
14
+ // happens.
15
+ //
16
+ // The level arriving on the wire is a REQUEST, and `normalizeAuthority` fails
17
+ // closed — absent or unrecognised is `read`. An older page that never heard of
18
+ // the field asks for the reading tools and gets them; silence never grants the
19
+ // most (fix.md P1.5: "switching providers never silently increases authority"
20
+ // covers switching anything else, too).
21
+
22
+ export const READ_TOOLS = Object.freeze([
23
+ "beatrina_status", "notebook_read", "chunk_read", "file_list", "file_read",
24
+ ]);
25
+
26
+ export const WRITE_TOOLS = Object.freeze([
27
+ "chunk_insert", "chunk_update", "chunk_run",
28
+ ]);
29
+
30
+ export const AUTHOR_TOOLS = Object.freeze([...READ_TOOLS, ...WRITE_TOOLS]);
31
+
32
+ export const AUTHORITY_LEVELS = Object.freeze(["text", "read", "author"]);
33
+
34
+ /** Read a level off the wire, failing closed. */
35
+ export function normalizeAuthority(value) {
36
+ const asked = String(value || "").toLowerCase();
37
+ return AUTHORITY_LEVELS.includes(asked) ? asked : "read";
38
+ }
39
+
40
+ /** The tools a level may use. `text` gets none. */
41
+ export function toolsFor(authority) {
42
+ if (authority === "author") return AUTHOR_TOOLS.slice();
43
+ if (authority === "read") return READ_TOOLS.slice();
44
+ return [];
45
+ }
46
+
47
+ /** The `--allowedTools` value for a spawned Claude Code turn at this level. */
48
+ export function allowedToolsArg(authority) {
49
+ return toolsFor(authority).map((name) => `mcp__beatrina__${name}`).join(",");
50
+ }
51
+
52
+ export default {
53
+ READ_TOOLS, WRITE_TOOLS, AUTHOR_TOOLS, AUTHORITY_LEVELS,
54
+ normalizeAuthority, toolsFor, allowedToolsArg,
55
+ };
@@ -26,15 +26,16 @@
26
26
  //
27
27
  // ── where a policy comes from ──────────────────────────────────────────────
28
28
  //
29
- // CARMAR_AI_POLICY=/etc/carmar/ai-policy.json a file the user cannot write
30
- // CARMAR_AI_PROVIDERS=anthropic,lmstudio the quick form
31
- // CARMAR_AI_LOCAL_ONLY=1 only on-machine providers
29
+ // BEATRINA_AI_POLICY=/etc/beatrina/ai-policy.json a file the user cannot write
30
+ // BEATRINA_AI_PROVIDERS=anthropic,lmstudio the quick form
31
+ // BEATRINA_AI_LOCAL_ONLY=1 only on-machine providers
32
32
  //
33
33
  // The file wins where both are set, because a file is the form an administrator
34
34
  // can own while an environment variable is inherited from whoever started the
35
35
  // process — on a shared host, that may be the user.
36
36
 
37
37
  import fs from "node:fs";
38
+ import { envReader } from "./env-names.mjs";
38
39
 
39
40
  /**
40
41
  * The providers whose text never leaves the machine.
@@ -54,7 +55,7 @@ export const KEY_PROVIDERS = Object.freeze(["openai", "anthropic", "google", "gr
54
55
  export const CLI_PROVIDERS = Object.freeze(["claudecode", "codex"]);
55
56
  export const ALL_PROVIDERS = Object.freeze([...ON_MACHINE_PROVIDERS, ...KEY_PROVIDERS, ...CLI_PROVIDERS]);
56
57
 
57
- const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
58
+ const sysEnv = envReader(process.env); // dual-read: BEATRINA_AI_POLICY or its older spelling (host/env-names.mjs)
58
59
  const asList = (v) => (v == null ? [] : (Array.isArray(v) ? v : [v]));
59
60
 
60
61
  /**
@@ -101,6 +102,12 @@ export function normaliseBaseUrls(raw) {
101
102
  return { values, errors };
102
103
  }
103
104
 
105
+ // Every key this file reads. A policy carrying anything else is a policy
106
+ // whose author meant something the supervisor will not do — `provider` for
107
+ // `providers` was accepted in silence and read as "permit all fifteen"
108
+ // (fix.md P1.4). An unknown key is a refusal, with the key named.
109
+ export const POLICY_KEYS = Object.freeze(["providers", "local_only", "note", "models", "base_urls"]);
110
+
104
111
  const policySplit = (value) => String(value || "").split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length);
105
112
  // Control characters are 0x00-0x1f and 0x7f — R's [[:cntrl:]], spelled out.
106
113
  // eslint-disable-next-line no-control-regex
@@ -115,12 +122,13 @@ const stripControl = (s) => String(s).replace(/[\u0000-\u001f\u007f]/g, " ");
115
122
  * named nothing / named something valid / named only junk — and conflating the
116
123
  * last two is the fail-open bug.
117
124
  */
118
- export function carmarAiPolicy(env = sysEnv) {
119
- const file = String(env("CARMAR_AI_POLICY", "")).trim();
125
+ export function beatrinaAiPolicy(env = sysEnv) {
126
+ const file = String(env("BEATRINA_AI_POLICY", "")).trim();
120
127
  let named = [];
121
128
  let models = {};
122
129
  let baseUrls = {};
123
- let localOnly = env("CARMAR_AI_LOCAL_ONLY", "") === "1";
130
+ let localOnly = env("BEATRINA_AI_LOCAL_ONLY", "") === "1";
131
+ let namedKeyPresent = false;
124
132
  let note = "";
125
133
  let source = "";
126
134
  let errors = [];
@@ -128,13 +136,25 @@ export function carmarAiPolicy(env = sysEnv) {
128
136
  if (file.length) {
129
137
  source = file;
130
138
  if (!fs.existsSync(file)) {
131
- errors.push(`CARMAR_AI_POLICY names a file that does not exist: ${file}`);
139
+ errors.push(`BEATRINA_AI_POLICY names a file that does not exist: ${file}`);
132
140
  } else {
133
141
  let doc = null;
134
142
  let parseError = "";
135
143
  try { doc = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { parseError = e.message; }
136
- if (parseError) errors.push(`CARMAR_AI_POLICY is not valid JSON: ${parseError}`);
137
- else {
144
+ if (parseError) errors.push(`BEATRINA_AI_POLICY is not valid JSON: ${parseError}`);
145
+ else if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
146
+ errors.push("BEATRINA_AI_POLICY must be a JSON object of policy keys.");
147
+ } else {
148
+ const unknownKeys = Object.keys(doc).filter((k) => !POLICY_KEYS.includes(k));
149
+ if (unknownKeys.length) {
150
+ errors.push(`AI policy: ${unknownKeys.map((k) => `'${k}'`).join(", ")} `
151
+ + `${unknownKeys.length === 1 ? "is not a policy key" : "are not policy keys"}. `
152
+ + `A policy may name: ${POLICY_KEYS.join(", ")}. A key nobody reads permits everything, which is never what the file meant.`);
153
+ }
154
+ // "the key is present" and "the key names something" are different
155
+ // facts: `providers: []` used to read as "said nothing", which the
156
+ // default answers with every provider.
157
+ namedKeyPresent = Object.prototype.hasOwnProperty.call(doc, "providers");
138
158
  named = asList(doc && doc.providers).flat().map((v) => String(v));
139
159
  if (doc && doc.local_only === true) localOnly = true;
140
160
  note = String(asList(doc && doc.note).flat()[0] ?? "").trim();
@@ -148,20 +168,34 @@ export function carmarAiPolicy(env = sysEnv) {
148
168
  errors = errors.concat(endpoints.errors);
149
169
  }
150
170
  }
151
- } else if (String(env("CARMAR_AI_PROVIDERS", "")).length) {
171
+ } else if (String(env("BEATRINA_AI_PROVIDERS", "")).length) {
152
172
  source = "env";
153
- named = policySplit(env("CARMAR_AI_PROVIDERS", ""));
173
+ named = policySplit(env("BEATRINA_AI_PROVIDERS", ""));
154
174
  } else if (localOnly) {
155
175
  source = "env";
156
176
  }
157
177
 
158
178
  const unknown = named.filter((n) => !ALL_PROVIDERS.includes(n));
159
- const asked = named.length > 0; // did the policy NAME anything at all?
179
+ // ASKED means the policy spoke about providers at all — including an empty
180
+ // list, which speaks and names nothing. Only silence inherits the default.
181
+ const asked = named.length > 0 || namedKeyPresent;
160
182
  named = named.filter((n) => ALL_PROVIDERS.includes(n));
161
183
 
162
184
  let providers = named.length ? named : (asked ? [] : [...ALL_PROVIDERS]);
163
185
  if (localOnly) providers = providers.filter((p) => ON_MACHINE_PROVIDERS.includes(p));
164
186
 
187
+ // local_only is a promise about where the words GO, so an endpoint that
188
+ // leaves the machine contradicts it — and an on-machine provider pointed at
189
+ // a hosted gateway is exactly how that happens without looking wrong.
190
+ if (localOnly) {
191
+ Object.entries(baseUrls).forEach(([provider, url]) => {
192
+ if (!LOOPBACK_RE.test(url)) {
193
+ errors.push(`AI policy: local_only is set, but base_urls.${provider} is ${url}, which leaves this machine. `
194
+ + "Point it at loopback, or drop local_only.");
195
+ }
196
+ });
197
+ }
198
+
165
199
  let set = source.length > 0 && errors.length === 0;
166
200
  if (set && !providers.length) {
167
201
  errors.push(`The AI policy permits no provider at all${
@@ -193,18 +227,6 @@ export function carmarAiPolicy(env = sysEnv) {
193
227
  export const aiProviderAllowed = (provider, policy) =>
194
228
  policy.set !== true || (typeof provider === "string" && provider.length > 0 && policy.providers.includes(provider));
195
229
 
196
- /**
197
- * May this model be used with this provider? TRUE when no models are pinned for
198
- * that provider — pinning is opt-in per provider. DISCLOSURE ONLY: a model id
199
- * is a field in a request body the page builds and sends itself, so there is no
200
- * door here to close.
201
- */
202
- export const aiModelAllowed = (provider, model, policy) => {
203
- if (policy.set !== true) return true;
204
- const allowed = policy.models[provider || ""];
205
- if (!allowed || !allowed.length) return true;
206
- return typeof model === "string" && model.length > 0 && allowed.includes(model);
207
- };
208
230
 
209
231
  /**
210
232
  * The sentence the notebook shows when something is refused. Names the
@@ -213,6 +235,6 @@ export const aiModelAllowed = (provider, model, policy) => {
213
235
  * message.
214
236
  */
215
237
  export const aiPolicyReason = (policy) => {
216
- const base = `This CarmaR is configured to allow only: ${policy.providers.join(", ")}.`;
238
+ const base = `This Beatrina is configured to allow only: ${policy.providers.join(", ")}.`;
217
239
  return policy.note.length ? `${base} ${policy.note}` : base;
218
240
  };
@@ -0,0 +1,194 @@
1
+ // bundle.mjs — `.btrn`, the Beatrina document that travels (docs/results-plan.md R2).
2
+ //
3
+ // A standard ZIP (host/zip.mjs) holding the document, its results and the files
4
+ // it needs — "transfer the whole document like carmnote" (owner, 2026-09-19): // beatrina-names: keep
5
+ //
6
+ // manifest.json format, version, app, created, title, what is inside
7
+ // (every entry: size + SHA-256), and the data: its mode
8
+ // and every file's path, size and SHA-256
9
+ // <name>.qmd the document, plain text (its own .Rmd/.md if it was one)
10
+ // results/results.json the beatrina-results/1 record (lib/document-results.js)
11
+ // results/figures/* the record's figures, stored (PNG is compressed already)
12
+ // data/<relative path> every file the document needs, deflated, at the path it
13
+ // uses: data, bibliography, citation style, images, included
14
+ // files, project settings (lib/bundle.js documentFiles)
15
+ //
16
+ // NO HISTORY, by the owner's decision (2026-09-19): Undo history holds every
17
+ // keystroke, deleted text included — a document sent to someone must not carry
18
+ // what its author removed. A CarmNote carries none either; the author's own
19
+ // history stays in their session's journal.
20
+ //
21
+ // DATA ≤ 100 MB (compressed, in total) travels inside. Past that it goes BESIDE
22
+ // the bundle, in `<name>.btrn-data/` at the same relative paths, and the manifest
23
+ // keeps each file's size and SHA-256 so the receiver can tell a missing or a
24
+ // changed file by name. A folder, not a second zip: big data stays usable as it
25
+ // is, and ZIP's 4 GB ceiling never comes up.
26
+ //
27
+ // Pure where it can be: buildBundle / readBundle take bytes and return bytes or
28
+ // parts; the plane (host/planes/bundle.mjs) does the file system and the doors.
29
+
30
+ import crypto from "node:crypto";
31
+ import path from "node:path";
32
+ import { writeZip, readZip, listZip, safeEntryName } from "./zip.mjs";
33
+
34
+ export const BUNDLE_FORMAT = "beatrina-bundle";
35
+ export const BUNDLE_VERSION = 1;
36
+ export const BUNDLE_EXT = ".btrn";
37
+ /** Data travels inside up to this many compressed bytes; past it, beside. */
38
+ export const DATA_INSIDE_MAX = 100 * 1024 * 1024;
39
+ /** Reading: the whole archive may not expand past this. */
40
+ export const BUNDLE_READ_MAX = 2 * 1024 ** 3;
41
+
42
+ export const sha256 = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
43
+ const fail = (code, message) => Object.assign(new Error(message), { code });
44
+
45
+ /** `/w/paper.btrn` → `/w/paper.btrn-data` — where beside-mode data lives. */
46
+ export const dataFolderFor = (bundlePath) => `${bundlePath}-data`;
47
+
48
+ /** A data path the bundle may carry: relative, safe, a file (not a folder). */
49
+ export function safeDataPath(rel) {
50
+ const r = String(rel ?? "").replace(/\\/g, "/");
51
+ return safeEntryName(r) && !r.endsWith("/") && r.length <= 512;
52
+ }
53
+
54
+ /**
55
+ * Build a .btrn.
56
+ *
57
+ * @param {object} parts
58
+ * @param {string} parts.source the document's text
59
+ * @param {string} parts.sourceName its name inside, e.g. "paper.qmd"
60
+ * @param {string} [parts.title]
61
+ * @param {string|null} [parts.results] the results record, JSON text (figures external)
62
+ * @param {Array<{name:string, data:Buffer, mime?:string}>} [parts.figures] results/figures/<name>
63
+ * @param {Array<{path:string, bytes:Buffer}>} [parts.data] files the code reads, by relative path
64
+ * @param {string} [parts.app] the app version that wrote it
65
+ * @param {Date} [parts.created]
66
+ * @param {number} [parts.insideMax] DATA_INSIDE_MAX unless a test says otherwise
67
+ * @param {string} [parts.bundleName] the .btrn's own file name, for the beside folder's name
68
+ * @returns {{zip:Buffer, manifest:object, beside:Array<{path:string, bytes:Buffer}>}}
69
+ * `beside` is the data to write in <bundle>-data/ when it did not fit inside
70
+ */
71
+ export function buildBundle(parts) {
72
+ const {
73
+ source, sourceName, title = "", results = null, figures = [],
74
+ data = [], app = "", created = new Date(), insideMax = DATA_INSIDE_MAX, bundleName = "",
75
+ } = parts || {};
76
+ if (typeof source !== "string") throw fail("bundle_input", "a bundle needs the document's source");
77
+ if (!/^[^/\\]+\.(qmd|Rmd|rmd|md)$/.test(String(sourceName || ""))) {
78
+ throw fail("bundle_input", `the document inside must be a .qmd, .Rmd or .md file name, not ${JSON.stringify(sourceName)}`);
79
+ }
80
+ const files = [];
81
+ const seen = new Set();
82
+ const add = (name, bytes, compress = true) => {
83
+ if (seen.has(name)) throw fail("bundle_input", `${name} twice`);
84
+ seen.add(name);
85
+ files.push({ name, data: bytes, compress });
86
+ };
87
+ add(sourceName, Buffer.from(source, "utf8"));
88
+ if (results != null) {
89
+ add("results/results.json", Buffer.from(String(results), "utf8"));
90
+ for (const f of figures || []) {
91
+ if (!safeEntryName(`results/figures/${f.name}`) || f.name.includes("/")) throw fail("bundle_input", `bad figure name ${JSON.stringify(f.name)}`);
92
+ add(`results/figures/${f.name}`, f.data, false);
93
+ }
94
+ }
95
+ const dataFiles = (data || []).map((d) => { // each: {path, bytes, kind?}
96
+ const rel = String(d.path).replace(/\\/g, "/");
97
+ if (!safeDataPath(rel)) throw fail("bundle_bad_path", `a data path must be relative and stay inside the document's folder: ${JSON.stringify(d.path)}`);
98
+ return { path: rel, bytes: d.bytes, size: d.bytes.length, sha256: sha256(d.bytes), kind: d.kind || "data" };
99
+ });
100
+
101
+ const manifestFor = (mode, dataEntries) => ({
102
+ format: BUNDLE_FORMAT, version: BUNDLE_VERSION, app, created: created.toISOString(), title,
103
+ source: sourceName,
104
+ entries: dataEntries.concat(files).map((f) => ({ name: f.name, size: f.data.length, sha256: sha256(f.data) })),
105
+ data: {
106
+ mode, ...(mode === "beside" ? { folder: `${path.basename(bundleName || sourceName.replace(/\.[^.]+$/, ".btrn"))}-data` } : {}),
107
+ files: dataFiles.map(({ path: p, size, sha256: h, kind }) => ({ path: p, size, sha256: h, kind })),
108
+ },
109
+ });
110
+ const zipWith = (manifest, dataEntries) => writeZip([
111
+ { name: "manifest.json", data: Buffer.from(JSON.stringify(manifest, null, 1), "utf8") },
112
+ ...files, ...dataEntries,
113
+ ], { date: created });
114
+
115
+ // Inside first; measure what the data costs compressed, and move it beside
116
+ // only when it passes the cap.
117
+ const inside = dataFiles.map((d) => ({ name: `data/${d.path}`, data: d.bytes, compress: true }));
118
+ let zip = zipWith(manifestFor(dataFiles.length ? "inside" : "none", inside), inside);
119
+ const dataBytes = listZip(zip).filter((e) => e.name.startsWith("data/")).reduce((a, e) => a + e.csize, 0);
120
+ if (dataBytes <= insideMax) {
121
+ return { zip, manifest: manifestFor(dataFiles.length ? "inside" : "none", inside), beside: [] };
122
+ }
123
+ const manifest = manifestFor("beside", []);
124
+ zip = zipWith(manifest, []);
125
+ return { zip, manifest, beside: dataFiles.map((d) => ({ path: d.path, bytes: d.bytes })), dataCompressed: dataBytes };
126
+ }
127
+
128
+ /**
129
+ * Read a .btrn: every part, VERIFIED. The manifest's SHA-256 for each entry is
130
+ * checked, and an entry the manifest does not list is refused — nothing is
131
+ * half-loaded.
132
+ *
133
+ * @param {Buffer} buf
134
+ * @returns {{manifest:object, source:string, sourceName:string, results:string|null,
135
+ * figures:Map<string,Buffer>, data:Map<string,Buffer>}}
136
+ */
137
+ export function readBundle(buf, { maxTotal = BUNDLE_READ_MAX } = {}) {
138
+ const all = readZip(buf, { maxTotal });
139
+ const rawManifest = all.get("manifest.json");
140
+ if (!rawManifest) throw fail("bundle_no_manifest", "not a Beatrina document (no manifest.json)");
141
+ let manifest;
142
+ try { manifest = JSON.parse(rawManifest.toString("utf8")); } catch { throw fail("bundle_bad_manifest", "manifest.json is not JSON"); }
143
+ if (manifest.format !== BUNDLE_FORMAT) throw fail("bundle_bad_manifest", `not a Beatrina document (format ${JSON.stringify(manifest.format)})`);
144
+ if (!(Number(manifest.version) >= 1)) throw fail("bundle_bad_manifest", "the manifest names no version");
145
+ if (Number(manifest.version) > BUNDLE_VERSION) {
146
+ throw fail("bundle_newer", `this document was written by a newer Beatrina (bundle version ${manifest.version}); update Beatrina to open it`);
147
+ }
148
+ const listed = new Map((manifest.entries || []).map((e) => [e.name, e]));
149
+ for (const [name, bytes] of all) {
150
+ if (name === "manifest.json") continue;
151
+ const want = listed.get(name);
152
+ if (!want) throw fail("bundle_unlisted", `${name} is in the archive but not in its manifest`);
153
+ if (want.size !== bytes.length || want.sha256 !== sha256(bytes)) throw fail("bundle_digest", `${name} does not match its manifest — the document is damaged`);
154
+ }
155
+ for (const name of listed.keys()) if (!all.has(name)) throw fail("bundle_missing", `the manifest lists ${name}, which is not in the archive`);
156
+ const sourceName = String(manifest.source || "");
157
+ if (!all.has(sourceName)) throw fail("bundle_missing", "the document itself is missing");
158
+ const figures = new Map();
159
+ const data = new Map();
160
+ for (const [name, bytes] of all) {
161
+ if (name.startsWith("results/figures/")) figures.set(name.slice("results/figures/".length), bytes);
162
+ else if (name.startsWith("data/")) data.set(name.slice("data/".length), bytes);
163
+ }
164
+ return {
165
+ manifest, sourceName, source: all.get(sourceName).toString("utf8"),
166
+ results: all.has("results/results.json") ? all.get("results/results.json").toString("utf8") : null,
167
+ figures, data,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * What opening a bundle would do to each data file, pure: compare the
173
+ * manifest's digest with the file at the document's folder.
174
+ *
175
+ * @param {object} manifest
176
+ * @param {(rel:string) => (Buffer|null)} readLocal the file beside the document, or null
177
+ * @returns {Array<{path:string, size:number, kind:string, state:'absent'|'same'|'differs'}>}
178
+ */
179
+ export function dataPlan(manifest, readLocal) {
180
+ return ((manifest && manifest.data && manifest.data.files) || []).map((f) => {
181
+ const local = readLocal(f.path);
182
+ const state = !local ? "absent" : sha256(local) === f.sha256 ? "same" : "differs";
183
+ return { path: f.path, size: f.size, kind: f.kind || "data", state };
184
+ });
185
+ }
186
+
187
+ /** `paper.btrn` → `paper.qmd` — the name the document takes inside. */
188
+ export function innerNameFor(bundlePath, format = "qmd") {
189
+ const stem = path.basename(String(bundlePath || "document")).replace(/\.btrn$/i, "") || "document";
190
+ const ext = { qmd: "qmd", rmd: "Rmd", md: "md" }[String(format).toLowerCase()] || "qmd";
191
+ return `${stem}.${ext}`;
192
+ }
193
+
194
+ export default { buildBundle, readBundle, dataPlan, innerNameFor, dataFolderFor, safeDataPath, sha256 };
@@ -13,6 +13,8 @@
13
13
  // client is not a browser and therefore is this same OS user; anywhere the port
14
14
  // is reachable by someone else the same header proves nothing, so it is refused.
15
15
 
16
+ import { envReader } from "./env-names.mjs";
17
+
16
18
  export const LOOPBACK_HOSTS = ["127.0.0.1", "::1", "[::1]", "localhost"];
17
19
 
18
20
  /** Split a comma/space-separated environment value into trimmed, non-empty parts. */
@@ -45,8 +47,8 @@ export function isLoopbackBind(bind) {
45
47
  * `errors` is EMPTY when the posture is safe to serve. A non-empty `errors`
46
48
  * must abort startup; it is never a warning.
47
49
  */
48
- export function carmarDeployment(port, env = envFromProcess()) {
49
- let bind = env("CARMAR_BIND", "127.0.0.1").trim();
50
+ export function beatrinaDeployment(port, env = envFromProcess()) {
51
+ let bind = env("BEATRINA_BIND", "127.0.0.1").trim();
50
52
  if (!bind) bind = "127.0.0.1";
51
53
  const loopback = isLoopbackBind(bind);
52
54
 
@@ -54,9 +56,9 @@ export function carmarDeployment(port, env = envFromProcess()) {
54
56
  // the same-origin case. Off loopback the server is behind a proxy, so its own
55
57
  // page arrives with the PROXY's origin — which the operator must name.
56
58
  const selfOrigins = [`http://${bind}:${port}`, `http://127.0.0.1:${port}`, `http://localhost:${port}`];
57
- const origins = unique([...selfOrigins, ...splitList(env("CARMAR_ORIGINS", ""))]);
59
+ const origins = unique([...selfOrigins, ...splitList(env("BEATRINA_ORIGINS", ""))]);
58
60
 
59
- let hostsEnv = splitList(env("CARMAR_HOSTS", ""));
61
+ let hostsEnv = splitList(env("BEATRINA_HOSTS", ""));
60
62
  const defaultHosts = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
61
63
  // A configured host may or may not carry a port. Accept both spellings so an
62
64
  // operator writing `stats.example.edu` is not silently refused behind a proxy
@@ -68,31 +70,31 @@ export function carmarDeployment(port, env = envFromProcess()) {
68
70
  // allowing an Origin-less client. Binding off loopback breaks it, but so does
69
71
  // a shared multi-user host or a container whose 127.0.0.1 is forwarded out,
70
72
  // so the posture is declarable in its own right.
71
- const requireOrigin = env("CARMAR_REQUIRE_ORIGIN", "") === "1" || !loopback;
73
+ const requireOrigin = env("BEATRINA_REQUIRE_ORIGIN", "") === "1" || !loopback;
72
74
 
73
- const trustProxy = env("CARMAR_TRUST_PROXY", "") === "1";
74
- const userHeader = env("CARMAR_USER_HEADER", "X-Forwarded-User").trim();
75
- const proxyAddrs = splitList(env("CARMAR_TRUSTED_PROXY", "127.0.0.1 ::1"));
76
- const allowOpen = env("CARMAR_ALLOW_UNAUTHENTICATED", "") === "1";
75
+ const trustProxy = env("BEATRINA_TRUST_PROXY", "") === "1";
76
+ const userHeader = env("BEATRINA_USER_HEADER", "X-Forwarded-User").trim();
77
+ const proxyAddrs = splitList(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"));
78
+ const allowOpen = env("BEATRINA_ALLOW_UNAUTHENTICATED", "") === "1";
77
79
 
78
80
  const errors = [];
79
81
  if (!loopback) {
80
82
  if (!hostsEnv.length) {
81
- errors.push(`CARMAR_BIND is ${bind} but CARMAR_HOSTS is unset. Off loopback the `
83
+ errors.push(`BEATRINA_BIND is ${bind} but BEATRINA_HOSTS is unset. Off loopback the `
82
84
  + "Host allow-list is the only defence against DNS rebinding, so it must "
83
- + "name the hostname readers will use (e.g. CARMAR_HOSTS=stats.example.edu).");
85
+ + "name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu).");
84
86
  }
85
87
  if (!trustProxy && !allowOpen) {
86
- errors.push(`CARMAR_BIND is ${bind} with no authentication in front of it. Put `
88
+ errors.push(`BEATRINA_BIND is ${bind} with no authentication in front of it. Put `
87
89
  + "Beatrina behind a reverse proxy that authenticates and set "
88
- + "CARMAR_TRUST_PROXY=1, or set CARMAR_ALLOW_UNAUTHENTICATED=1 to serve code "
90
+ + "BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve code "
89
91
  + "execution to everyone who can reach this port. See docs/server.md.");
90
92
  }
91
93
  if (trustProxy && !userHeader) {
92
- errors.push("CARMAR_TRUST_PROXY=1 requires a non-empty CARMAR_USER_HEADER.");
94
+ errors.push("BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.");
93
95
  }
94
96
  if (trustProxy && !proxyAddrs.length) {
95
- errors.push("CARMAR_TRUST_PROXY=1 requires CARMAR_TRUSTED_PROXY to name the proxy's address.");
97
+ errors.push("BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.");
96
98
  }
97
99
  }
98
100
 
@@ -127,7 +129,7 @@ export function carmarDeployment(port, env = envFromProcess()) {
127
129
  *
128
130
  * @param {{REMOTE_ADDR?: string} & Record<string, string>} req a Rook-shaped
129
131
  * request: `REMOTE_ADDR` plus `HTTP_*` header keys
130
- * @param {ReturnType<typeof carmarDeployment>} deployment
132
+ * @param {ReturnType<typeof beatrinaDeployment>} deployment
131
133
  * @returns {string}
132
134
  */
133
135
  export function proxyUser(req, deployment) {
@@ -150,9 +152,14 @@ export function rookOf(req) {
150
152
  return out;
151
153
  }
152
154
 
153
- /** A getter with `Sys.getenv`'s signature over `process.env`. */
155
+ /**
156
+ * A getter with `Sys.getenv`'s signature over `process.env` — dual-read: a
157
+ * `CARMAR_*` name asked for here is also answered by its `BEATRINA_*` spelling
158
+ * (host/env-names.mjs), so the call sites above keep the vocabulary the
159
+ * deployment vectors in test/host-deployment.test.mjs use.
160
+ */
154
161
  export function envFromProcess(source = process.env) {
155
- return (name, unset = "") => (source[name] == null ? unset : String(source[name]));
162
+ return envReader(source);
156
163
  }
157
164
 
158
165
  function unique(list) {
@@ -4,11 +4,12 @@
4
4
  // can name. It speaks the wire worker.R and worker.py speak, so everything but
5
5
  // three answers is StdioEngine's:
6
6
  //
7
- // · WHICH NODE. The host's own `process.execPath` when the host is a plain
8
- // `node host/main.mjs` — the Node the R launcher already found and
9
- // version-checked. A packaged single-executable host (node:sea) cannot run
10
- // a script, so there the engine needs `BEATRIX_NODE` / `CARMAR_NODE`, and
11
- // says so on the ready frame rather than appearing and failing.
7
+ // · WHICH NODE. The host's own `process.execPath` — the Node the R launcher
8
+ // already found and version-checked — or `BEATRIX_NODE`/`BEATRINA_NODE`
9
+ // when one is named. There is no self-hosted rung: the node:sea build that
10
+ // needed one is retired (f5979e5, 2026-09-16), and every distribution now
11
+ // stages `engines/js/` beside the host, so the worker is a script on disk
12
+ // in all three of them.
12
13
  // · A CONSOLE LINE MEANS NOTHING. The worker reads commands only; there is
13
14
  // no prompt, so canInput and canDebug are false and the plane refuses
14
15
  // input_reply and debug_cmd for this engine by those capabilities.
@@ -18,6 +19,7 @@
18
19
 
19
20
  import fs from "node:fs";
20
21
  import { StdioEngine } from "./engine-stdio.mjs";
22
+ import { mirrorEnv, readEnv } from "./env-names.mjs";
21
23
  import { findInterruptTool } from "./windows-runtime.mjs";
22
24
 
23
25
  export const JS_MIN_NODE = 22;
@@ -26,17 +28,12 @@ export const JS_MIN_NODE = 22;
26
28
  * Find a Node for the JavaScript engine, and SAY WHAT WAS DECIDED.
27
29
  * @returns {{available: boolean, path: string, version: string, detail: string}}
28
30
  */
29
- export function detectNode({ env = process.env, packaged = false, execPath = process.execPath, versions = process.versions } = {}) {
30
- const explicit = env.BEATRIX_NODE || env.CARMAR_NODE || "";
31
+ export function detectNode({ env = process.env, execPath = process.execPath, versions = process.versions } = {}) {
32
+ const explicit = env.BEATRIX_NODE || readEnv(env, "NODE");
31
33
  if (explicit) {
32
34
  if (!fs.existsSync(explicit)) return { available: false, path: "", version: "", detail: `BEATRIX_NODE points at ${explicit}, which does not exist.` };
33
35
  return { available: true, path: explicit, version: "", detail: `Node at ${explicit} (BEATRIX_NODE)` };
34
36
  }
35
- if (packaged) {
36
- // A single executable IS a Node: it runs the worker bundled inside it when
37
- // started with BEATRIX_ENGINE_ROLE=js (the retired executable build; kept for any old binary).
38
- return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node} (inside this executable)`, selfHosted: true };
39
- }
40
37
  const major = Number(String(versions.node || "").split(".")[0]);
41
38
  if (!(major >= JS_MIN_NODE)) return { available: false, path: "", version: versions.node || "", detail: `JavaScript chunks need Node ${JS_MIN_NODE} or newer; this host runs on ${versions.node}.` };
42
39
  return { available: true, path: execPath, version: versions.node, detail: `Node ${versions.node}` };
@@ -51,10 +48,9 @@ export class JsEngine extends StdioEngine {
51
48
  * @param {Record<string,string>} [opts.env]
52
49
  * @param {string} [opts.cwd]
53
50
  */
54
- constructor({ workerPath, node, env = {}, cwd, platform = process.platform, selfHosted = false } = {}) {
51
+ constructor({ workerPath, node, env = {}, cwd, platform = process.platform } = {}) {
55
52
  super({ language: "js", workerPath, env, cwd, platform });
56
- this.selfHosted = selfHosted;
57
- if (!selfHosted && (!workerPath || !fs.existsSync(workerPath))) throw new Error(`JsEngine: no worker at ${workerPath}`);
53
+ if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`JsEngine: no worker at ${workerPath}`);
58
54
  if (!node) throw new Error("JsEngine: no Node found (set BEATRIX_NODE)");
59
55
  this.node = node;
60
56
  if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
@@ -69,8 +65,7 @@ export class JsEngine extends StdioEngine {
69
65
  // which Node still labels experimental and announces on stderr — into the
70
66
  // first chunk that imports anything. The label is Node's; the output is not
71
67
  // the user's, so it is not printed.
72
- if (this.selfHosted) return { bin: this.node, args: [], env: { CARMAR_WORKER_MODE: "batch", BEATRIX_ENGINE_ROLE: "js" } };
73
- return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: { CARMAR_WORKER_MODE: "batch" } };
68
+ return { bin: this.node, args: ["--disable-warning=ExperimentalWarning", this.workerPath], env: mirrorEnv({ BEATRINA_WORKER_MODE: "batch" }) };
74
69
  }
75
70
 
76
71
  /**
@@ -347,16 +347,58 @@ export class EnginePool extends EventEmitter {
347
347
  freshResumeReport(now) { return this.primary ? this.primary.freshResumeReport(now) : null; }
348
348
  cancelSuspend(rec) { return this.primary ? this.primary.cancelSuspend(rec) : false; }
349
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;
394
+ }
395
+
350
396
  inputReply(cmd, rec) {
351
- const waiting = [...this.planes.values()].find((p) => p.engine && p.inputWaiting);
352
- if (waiting) return waiting.inputReply(cmd, rec);
353
- return this.primary ? this.primary.inputReply(cmd, rec) : undefined;
397
+ return this.controlPlane(cmd, rec, "inputWaiting", "input_reply")?.inputReply(cmd, rec);
354
398
  }
355
399
 
356
400
  debugCmd(cmd, rec) {
357
- const paused = [...this.planes.values()].find((p) => p.engine && p.debugPaused);
358
- if (paused) return paused.debugCmd(cmd, rec);
359
- return this.primary ? this.primary.debugCmd(cmd, rec) : undefined;
401
+ return this.controlPlane(cmd, rec, "debugPaused", "debug_cmd")?.debugCmd(cmd, rec);
360
402
  }
361
403
 
362
404
  runstate(cmd, rec) {