beatrina 0.8.6 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +156 -26
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +271 -35
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
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 (the CARMAR_* names are the WIRE the page and its launchers speak; they
9
- // are kept on purpose — renaming them is WP7's job, with the launchers):
10
- // CARMAR_PORT (4747), CARMAR_PORT_STRICT=1, CARMAR_BIND, CARMAR_HOSTS …
11
- // (see deployment.mjs), CARMAR_RSCRIPT, CARMAR_KERNEL_BUILD,
12
- // CARMAR_INSTALLED_BUILD, CARMAR_LOG, CARMAR_RUNTIME_DIR,
13
- // CARMAR_NO_RUNTIME_FILE=1, CARMAR_LINGER, CARMAR_BOOT_GRACE,
14
- // CARMAR_WORKER_DEADLINE_SCALE, CARMAR_WORKER_RESPAWN_MAX, CARMAR_OPEN_FILE,
15
- // CARMAR_HOST_ROOT, CARMAR_KERNEL_DIR (WP7: where the files are when the
16
- // host is not running from a source checkout — see hostRoot/kernelDir).
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 { carmarDeployment } from "./deployment.mjs";
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 = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
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
- * A PACKAGED binary has no module path on disk, so it answers from the folder
84
- * it sits in — which is also the rule the page pin needs: `carmar_V<build>.html`
85
- * beside the binary, exactly as spike/notebook-page.R puts it beside kernel/.
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("CARMAR_HOST_ROOT").trim();
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 CARMAR_KERNEL_DIR when a launcher knows better.
111
+ * Named by BEATRINA_KERNEL_DIR when a launcher knows better.
102
112
  */
103
113
  function kernelDir(base) {
104
- const dictated = env("CARMAR_KERNEL_DIR").trim();
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("CARMAR_KERNEL_BUILD").trim();
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("CARMAR_KERNEL_BUILD").trim();
141
- if (dictated) return env("CARMAR_INSTALLED_BUILD").trim() || dictated;
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 = `carmar_V${build}.html`;
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
- for (const p of [path.join(spike, "..", "dist", wanted), path.join(spike, "..", wanted)]) if (fs.existsSync(p)) return path.resolve(p);
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("CARMAR_PORT", "4747"), 10);
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("CARMAR_WAIT_PORT", "0"));
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("CARMAR_PORT_STRICT") === "1") {
200
- console.error(`Beatrina refuses to start: CARMAR_PORT=${wanted} is already in use and CARMAR_PORT_STRICT=1 forbids moving to another port.`);
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, then let the OS pick.
204
- for (let p = 4747; p <= 4947; p += 1) if (!(await portTaken(p))) return p;
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 CarmaR's doors, which write none.
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): CARMAR_PORT, CARMAR_HOSTS and CARMAR_BIND may all come from it, and the
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 = carmarDeployment(port, env);
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("CARMAR_LOG");
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("CARMAR_WORKER_DEADLINE_SCALE", "1")) > 0 ? Number(env("CARMAR_WORKER_DEADLINE_SCALE", "1")) : 1;
260
- const respawnMax = Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) >= 1 ? Number.parseInt(env("CARMAR_WORKER_RESPAWN_MAX", "10"), 10) : 10;
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("CARMAR_ROOT")) childEnv.CARMAR_ROOT = env("CARMAR_ROOT");
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.CARMAR_HANDOFF_DIR = handoffDir;
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("CARMAR_NO_PYTHON") === "1"
279
- ? { available: false, detail: "The Python engine is disabled (CARMAR_NO_PYTHON=1).", searched: [] }
280
- : await detectPython(process.env);
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("CARMAR_NO_JS") === "1"
284
- ? { available: false, detail: "The JavaScript engine is disabled (CARMAR_NO_JS=1).", path: "", version: "" }
285
- : !packaged && !fs.existsSync(jsWorker)
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, packaged });
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, selfHosted: Boolean(nodeFound.selfHosted), env: { ...childEnv, ...settings.childEnv() } }) },
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("CARMAR_FILE_LAUNCH_CAP")) ? env("CARMAR_FILE_LAUNCH_CAP") : secureToken(32);
354
- delete process.env.CARMAR_FILE_LAUNCH_CAP;
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 CarmaR's.
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("CARMAR_OPEN_FILE")) runtimeRecord.document = env("CARMAR_OPEN_FILE");
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("CARMAR_SESSION_TITLE").trim();
462
+ const inheritedTitle = env("BEATRINA_SESSION_TITLE").trim();
363
463
  if (inheritedTitle) runtimeRecord.title = inheritedTitle.slice(0, 120);
364
- const handoffFrom = env("CARMAR_HANDOFF_FROM").trim();
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 }); return true; } catch { return false; }
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("CARMAR_OPEN_FILE") || null,
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("CARMAR_LISTEN") === "1") runtimeRecord.listen = true;
435
- if (env("CARMAR_NO_RUNTIME_FILE") !== "1") {
436
- const dir = env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
437
- try { fs.mkdirSync(dir, { recursive: true }); runtimeFile = path.join(dir, `kernel-${boundPort}.json`); } catch { runtimeFile = ""; }
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("CARMAR_ROOT") });
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. The kernel stays hidden plumbing. CARMAR_OPENER is the seam a
445
- // test uses so nothing opens on a real desktop.
446
- if (process.argv.slice(2).includes("--open")) {
447
- const target = fileUrl || url;
448
- const opener = String(process.env.CARMAR_OPENER || "");
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 CARMAR_LINGER, so the
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("CARMAR_BOOT_GRACE", "180"))) ? Number(env("CARMAR_BOOT_GRACE", "180")) : 180;
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();