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
@@ -1,7 +1,7 @@
1
1
  // update.mjs — signed product updates: two ops, and a very narrow API.
2
2
  //
3
3
  // A port of the `update_*` half of spike/serve.R. The updater is a
4
- // PRODUCT-OWNED SCRIPT injected by the launcher (`CARMAR_UPDATE_SCRIPT`). The
4
+ // PRODUCT-OWNED SCRIPT injected by the launcher (`BEATRINA_UPDATE_SCRIPT`). The
5
5
  // browser never supplies a path or a command line: it may name one action from
6
6
  // a fixed four-word vocabulary, and the supervisor starts that exact script
7
7
  // directly, as an argument vector.
@@ -10,7 +10,7 @@
10
10
  // not poll a feed and has no clock of its own — that belongs to the R package's
11
11
  // `check_upgrade()`, once a day, at a door the person opened. All this plane
12
12
  // can do is read the script's six-field status record and ask it to act, which
13
- // is why `CARMAR_NO_UPDATE_CHECK=1` holds here trivially: with no script there
13
+ // is why `BEATRINA_NO_UPDATE_CHECK=1` holds here trivially: with no script there
14
14
  // is no process, and with a script there is still no feed.
15
15
  //
16
16
  // Who may ask: page-only in BOTH senses (agentRefused at the seam,
@@ -28,12 +28,13 @@
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
30
  import { spawn, spawnSync } from "node:child_process";
31
+ import { whichSync } from "../which.mjs";
32
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
31
33
 
32
- const PAGE_ONLY_CLASSES = ["served", "file", "local"];
33
34
  const OPS = ["update_status", "update_action"];
34
35
  const AGENT_WHY = {
35
36
  update_status: "Agents cannot inspect desktop update state.",
36
- update_action: "Agents cannot install, defer, or roll back CarmaR.",
37
+ update_action: "Agents cannot install, defer, or roll back Beatrina.",
37
38
  };
38
39
  const ACTIONS = ["check", "defer", "install", "rollback"];
39
40
  const STATES = ["unknown", "current", "available", "deferred", "installing",
@@ -55,14 +56,6 @@ export function updateRunnerSpec(script, args = [], platform = process.platform)
55
56
  return { command: "/bin/sh", args: [script, ...args] };
56
57
  }
57
58
 
58
- function whichSync(name) {
59
- for (const dir of String(process.env.PATH || "").split(path.delimiter).filter(Boolean)) {
60
- const cand = path.join(dir, name);
61
- try { fs.accessSync(cand, fs.constants.X_OK); return cand; } catch { /* next */ }
62
- }
63
- return "";
64
- }
65
-
66
59
  // eslint-disable-next-line no-control-regex
67
60
  const updateText = (x, max = 240) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, max).trim();
68
61
  const updateVersion = (x) => {
@@ -74,7 +67,7 @@ export function createPlane({ env, audit }) {
74
67
  const job = { proc: null, action: "", started: 0 };
75
68
 
76
69
  const scriptPath = (deployment) => {
77
- const script = env("CARMAR_UPDATE_SCRIPT", "").trim();
70
+ const script = env("BEATRINA_UPDATE_SCRIPT", "").trim();
78
71
  if (deployment.loopback !== true || !script || !fs.existsSync(script)) return "";
79
72
  const real = fs.realpathSync(script);
80
73
  return updateRunnerSpec(real) ? real : "";
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Three layers, searched in order, exactly as .libPaths() is:
11
11
  //
12
- // user R_user_dir("carmar","data")/plugins/<kind>/<id>/ the ONLY layer written
13
- // system $CARMAR_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
12
+ // user R_user_dir("beatrina","data")/plugins/<kind>/<id>/ the ONLY layer written
13
+ // system $BEATRINA_PLUGIN_DIR/<kind>/<id>/ an administrator's, read-only
14
14
  // shipped <kernel>/plugins/<kind>/<id>/ the bundle's, read-only
15
15
  //
16
16
  // The first OK copy of an id wins. A BROKEN copy never wins: integrity is
@@ -32,15 +32,20 @@ import crypto from "node:crypto";
32
32
  import fs from "node:fs";
33
33
  import os from "node:os";
34
34
  import path from "node:path";
35
- import { appId } from "./user-dirs.mjs";
35
+ import { appId, rUserDir } from "./user-dirs.mjs";
36
36
 
37
37
  export const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
38
38
  export const PLUGIN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
39
39
  export const PLUGIN_MANIFEST = "plugin.json";
40
40
  export const PLUGIN_MANIFEST_MAX_BYTES = 65536;
41
41
  export const PLUGIN_FETCH_TIMEOUT_S = 20;
42
- export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/carmar/plugin-policy.json";
43
- export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/carmar/plugins";
42
+ export const PLUGIN_POLICY_DEFAULT_FILE = "/etc/beatrina/plugin-policy.json";
43
+ export const PLUGIN_SYSTEM_DEFAULT_DIR = "/etc/beatrina/plugins";
44
+ // Where an administrator deployed them for Beatrina 0.8.x: read when the current path is absent.
45
+ // beatrina-names: keep-start
46
+ const LEGACY_PLUGIN_POLICY_FILE = "/etc/carmar/plugin-policy.json";
47
+ const LEGACY_PLUGIN_SYSTEM_DIR = "/etc/carmar/plugins";
48
+ // beatrina-names: keep-end
44
49
 
45
50
  /** A CROSS-LANGUAGE CONTRACT with `EDITOR_TOKENS` in lib/editor-themes.js. */
46
51
  export const THEME_TOKENS = Object.freeze([
@@ -130,8 +135,8 @@ export function cslLooksLikeStyle(file) {
130
135
  }
131
136
 
132
137
  /** The registry a `csl` install fetches by name from. Env-driven, as in R. */
133
- export const cslIndexUrl = (env) => env("CARMAR_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
134
- export const cslStyleBase = (env) => env("CARMAR_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
138
+ export const cslIndexUrl = (env) => env("BEATRINA_CSL_INDEX_URL", "https://www.zotero.org/styles-files/styles.json");
139
+ export const cslStyleBase = (env) => env("BEATRINA_CSL_STYLE_BASE", "https://www.zotero.org/styles/");
135
140
 
136
141
  /* ── the kinds table ──────────────────────────────────────────────────────
137
142
  *
@@ -288,37 +293,12 @@ export function urlHost(url) {
288
293
  return m[0].replace(/^https?:\/\//, "").replace(/:[0-9]+$/, "").toLowerCase();
289
294
  }
290
295
 
291
- /**
292
- * `tools::R_user_dir(pkg, which)`, in JavaScript.
293
- *
294
- * The whole plugin store, the CSL index cache and every test that relocates
295
- * them turn on this function agreeing with R's, so it is a transcription of
296
- * R 4.5's source rather than a guess: the env var, then XDG, then the
297
- * platform's own place, then `R/<package>` under it.
298
- */
299
- export function rUserDir(pkg, which, env) {
300
- const home = os.homedir();
301
- const win = process.platform === "win32";
302
- const mac = process.platform === "darwin";
303
- let p = "";
304
- if (which === "data") {
305
- p = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
306
- || (win ? path.join(env("APPDATA"), "R", "data")
307
- : mac ? path.join(home, "Library", "Application Support", "org.R-project.R")
308
- : path.join(home, ".local", "share"));
309
- } else if (which === "cache") {
310
- p = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
311
- || (win ? path.join(env("LOCALAPPDATA"), "R", "cache")
312
- : mac ? path.join(home, "Library", "Caches", "org.R-project.R")
313
- : path.join(home, ".cache"));
314
- } else {
315
- p = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
316
- || (win ? path.join(env("APPDATA"), "R", "config")
317
- : mac ? path.join(home, "Library", "Preferences", "org.R-project.R")
318
- : path.join(home, ".config"));
319
- }
320
- return path.join(p, "R", pkg);
321
- }
296
+ // rUserDir is IMPORTED. Both copies described themselves as faithful
297
+ // transcriptions of R 4.5's tools::R_user_dir, and they had already diverged on
298
+ // the fall-through branch — this one answered "config" where the other answered
299
+ // "cache" for an unrecognised `which`. Two transcriptions of one R function that
300
+ // already disagree is the definition of drift, and host/user-dirs.mjs explains
301
+ // at length why the host must not invent a second layout.
322
302
 
323
303
  /* ── the administrator's policy ──────────────────────────────────────────── */
324
304
 
@@ -333,7 +313,7 @@ export function rUserDir(pkg, which, env) {
333
313
  * network:boolean, kinds:string[], unknown:string[], note:string,
334
314
  * system_dir:string, errors:string[]}}
335
315
  */
336
- export function carmarPluginPolicy(env, {
316
+ export function beatrinaPluginPolicy(env, {
337
317
  defaultFile = PLUGIN_POLICY_DEFAULT_FILE, defaultDir = PLUGIN_SYSTEM_DEFAULT_DIR,
338
318
  } = {}) {
339
319
  const kinds_table = pluginKinds(env);
@@ -344,19 +324,22 @@ export function carmarPluginPolicy(env, {
344
324
  let sourcesRaw = null;
345
325
  let kindsRaw = null;
346
326
  let note = "";
347
- let systemDir = env("CARMAR_PLUGIN_DIR", "").trim();
327
+ let systemDir = env("BEATRINA_PLUGIN_DIR", "").trim();
348
328
  if (!systemDir && defaultDir && dirExists(defaultDir)) systemDir = defaultDir;
329
+ // The older deployment path counts only for the default, never for a caller's own.
330
+ if (!systemDir && defaultDir === PLUGIN_SYSTEM_DEFAULT_DIR && dirExists(LEGACY_PLUGIN_SYSTEM_DIR)) systemDir = LEGACY_PLUGIN_SYSTEM_DIR;
349
331
 
350
- let file = env("CARMAR_PLUGIN_POLICY", "").trim();
332
+ let file = env("BEATRINA_PLUGIN_POLICY", "").trim();
351
333
  if (!file && defaultFile && fileExists(defaultFile)) file = defaultFile;
334
+ if (!file && defaultFile === PLUGIN_POLICY_DEFAULT_FILE && fileExists(LEGACY_PLUGIN_POLICY_FILE)) file = LEGACY_PLUGIN_POLICY_FILE;
352
335
  if (file) {
353
336
  source = file;
354
337
  if (!fileExists(file)) {
355
- errors.push(`CARMAR_PLUGIN_POLICY names a file that does not exist: ${file}`);
338
+ errors.push(`BEATRINA_PLUGIN_POLICY names a file that does not exist: ${file}`);
356
339
  } else {
357
340
  let doc = null;
358
341
  try { doc = JSON.parse(fs.readFileSync(file, "utf8")); }
359
- catch (e) { errors.push(`CARMAR_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
342
+ catch (e) { errors.push(`BEATRINA_PLUGIN_POLICY is not valid JSON: ${e.message}`); }
360
343
  if (doc && typeof doc === "object") {
361
344
  install = String(doc.install ?? "").trim();
362
345
  if (doc.sources != null) sourcesRaw = [].concat(doc.sources).map(String);
@@ -367,9 +350,9 @@ export function carmarPluginPolicy(env, {
367
350
  }
368
351
  }
369
352
  } else {
370
- if (env("CARMAR_PLUGIN_INSTALL", "")) { source = "env"; install = env("CARMAR_PLUGIN_INSTALL", "").trim(); }
371
- if (env("CARMAR_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("CARMAR_PLUGIN_SOURCES", "")); }
372
- if (env("CARMAR_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("CARMAR_PLUGIN_KINDS", "")); }
353
+ if (env("BEATRINA_PLUGIN_INSTALL", "")) { source = "env"; install = env("BEATRINA_PLUGIN_INSTALL", "").trim(); }
354
+ if (env("BEATRINA_PLUGIN_SOURCES", "")) { source = "env"; sourcesRaw = policySplit(env("BEATRINA_PLUGIN_SOURCES", "")); }
355
+ if (env("BEATRINA_PLUGIN_KINDS", "")) { source = "env"; kindsRaw = policySplit(env("BEATRINA_PLUGIN_KINDS", "")); }
373
356
  }
374
357
 
375
358
  if (!install) install = "user";
@@ -432,11 +415,11 @@ export function pluginDefaultHosts(env) {
432
415
  /** The sentence shown when the policy refuses something. */
433
416
  export function pluginPolicyReason(policy, what = "install") {
434
417
  const base = {
435
- install: "This CarmaR is configured so that plugins are not installed by users; the set is fixed by the deployment.",
436
- network: "This CarmaR is configured to fetch no plugins from the network; a plugin arrives as a file.",
437
- host: `This CarmaR is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
438
- kind: "This CarmaR is configured without that plugin kind.",
439
- }[what] || "This CarmaR's plugin policy refuses that.";
418
+ install: "This Beatrina is configured so that plugins are not installed by users; the set is fixed by the deployment.",
419
+ network: "This Beatrina is configured to fetch no plugins from the network; a plugin arrives as a file.",
420
+ host: `This Beatrina is configured to fetch plugins only from: ${policy.sources.join(", ")}.`,
421
+ kind: "This Beatrina is configured without that plugin kind.",
422
+ }[what] || "This Beatrina's plugin policy refuses that.";
440
423
  return policy.note ? `${base} ${policy.note}` : base;
441
424
  }
442
425
 
@@ -833,6 +816,6 @@ export function pluginPolicyDisclosure(here, policy, env) {
833
816
  /** ONE policy per host process, as serve.R has ONE `plugin_policy`. */
834
817
  let cachedPolicy = null;
835
818
  export function pluginPolicyOnce(env, opts) {
836
- if (!cachedPolicy) cachedPolicy = carmarPluginPolicy(env, opts);
819
+ if (!cachedPolicy) cachedPolicy = beatrinaPluginPolicy(env, opts);
837
820
  return cachedPolicy;
838
821
  }
@@ -0,0 +1,124 @@
1
+ // recent-documents.mjs — the rules of the machine's list of documents this
2
+ // person opened, with no file system in them.
3
+ //
4
+ // One record per document: `{name, path, when}` — the name a row shows, the
5
+ // ABSOLUTE path a reopen reads, and the millisecond clock of the last time it
6
+ // was opened or saved. Newest first, one row per path, twenty deep.
7
+ //
8
+ // These rules are the page's (lib/recent-files.js), written a second time in
9
+ // the host, and test/recent-documents.test.mjs pins the two equal on the same
10
+ // inputs. The page keeps a cache of the list in its origin's storage and hands
11
+ // it over once per connection (`merge`), so a page and a host that disagreed
12
+ // about what a valid row is, or which of two rows of one path wins, would
13
+ // merge into a list neither of them wrote. Keep the two files in step.
14
+ //
15
+ // Pure: every function takes a list and returns a new one; `now` is a
16
+ // parameter so a test can pin what the clock does.
17
+
18
+ export const RECENT_DOCUMENTS_MAX = 20;
19
+ const PATH_MAX = 4096;
20
+ const NAME_MAX = 255;
21
+
22
+ // A POSIX root, a Windows drive (`C:\` or `C:/`) or a UNC share. The kernel
23
+ // hands the page resolved absolute paths, so a relative one is a row that was
24
+ // never openable — a picker's bare filename, a typo — and is dropped.
25
+ const ABSOLUTE_PATH = /^(\/|[A-Za-z]:[\\/]|\\\\)/;
26
+
27
+ const baseName = (p) => String(p || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
28
+
29
+ /**
30
+ * One record as the list keeps it, or null when it could not be reopened.
31
+ *
32
+ * The path must be absolute, printable and bounded; the name is trimmed,
33
+ * stripped of line breaks and bounded, and falls back to the path's last
34
+ * segment; `when` is a positive millisecond clock, else `now`.
35
+ *
36
+ * @param {*} entry
37
+ * @param {number} [now]
38
+ * @returns {{name:string, path:string, when:number}|null}
39
+ */
40
+ export function cleanRecentDocument(entry, now = Date.now()) {
41
+ if (!entry || typeof entry !== "object") return null;
42
+ const path = typeof entry.path === "string" ? entry.path : "";
43
+ if (!path || path.length > PATH_MAX || /[\r\n\0]/.test(path) || !ABSOLUTE_PATH.test(path)) return null;
44
+ const rawName = typeof entry.name === "string" ? entry.name.replace(/[\r\n\0]/g, " ").trim() : "";
45
+ const name = (rawName || baseName(path) || path).slice(0, NAME_MAX);
46
+ const clock = Number(entry.when);
47
+ const when = Number.isFinite(clock) && clock > 0 ? Math.floor(clock) : now;
48
+ return { name, path, when };
49
+ }
50
+
51
+ /**
52
+ * The list as it is kept: invalid rows dropped, one row per path — the one
53
+ * with the newest `when` wins, and on a tie the one that came FIRST — newest
54
+ * first, capped at RECENT_DOCUMENTS_MAX.
55
+ *
56
+ * The tie rule is what makes `note` deterministic: a row just noted is put in
57
+ * front with the clock's value, and two notes within one millisecond keep
58
+ * their order rather than being re-sorted by path. The sort is stable, so
59
+ * rows of one `when` keep the order they arrived in.
60
+ *
61
+ * @param {Array} list
62
+ * @param {number} [now]
63
+ * @returns {Array<{name:string, path:string, when:number}>}
64
+ */
65
+ export function cleanRecentDocuments(list, now = Date.now()) {
66
+ const byPath = new Map();
67
+ (Array.isArray(list) ? list : []).forEach((raw) => {
68
+ const row = cleanRecentDocument(raw, now);
69
+ if (!row) return;
70
+ const have = byPath.get(row.path);
71
+ if (!have || row.when > have.when) byPath.set(row.path, row);
72
+ });
73
+ return [...byPath.values()].sort((a, b) => b.when - a.when).slice(0, RECENT_DOCUMENTS_MAX);
74
+ }
75
+
76
+ /** How many rows of `list` `cleanRecentDocuments` would drop as unopenable. */
77
+ export function countUnopenable(list, now = Date.now()) {
78
+ return (Array.isArray(list) ? list : []).filter((raw) => !cleanRecentDocument(raw, now)).length;
79
+ }
80
+
81
+ /**
82
+ * Remember a document: it goes to the top, stamped `now`, and any older row
83
+ * of the same path goes.
84
+ *
85
+ * @param {Array} list
86
+ * @param {{name?:string, path:string}} document
87
+ * @param {number} [now]
88
+ */
89
+ export function noteRecentDocument(list, document, now = Date.now()) {
90
+ const row = cleanRecentDocument({ ...(document || {}), when: now }, now);
91
+ if (!row) return cleanRecentDocuments(list, now);
92
+ return cleanRecentDocuments([row, ...(Array.isArray(list) ? list : [])], now);
93
+ }
94
+
95
+ /**
96
+ * Drop one path. Clearing the whole list was the only deletion once, so a
97
+ * document that had moved could be removed only by throwing away the others.
98
+ *
99
+ * @param {Array} list
100
+ * @param {string} path
101
+ * @param {number} [now]
102
+ */
103
+ export function forgetRecentDocument(list, path, now = Date.now()) {
104
+ const gone = String(path || "");
105
+ return cleanRecentDocuments(list, now).filter((row) => row.path !== gone);
106
+ }
107
+
108
+ /**
109
+ * Two lists as one: every path of either, the newer row of each, newest
110
+ * first, capped. `a` comes first so that on a tie of `when` its row wins —
111
+ * the host passes the machine's list as `a` and a page's cache as `b`.
112
+ *
113
+ * @param {Array} a
114
+ * @param {Array} b
115
+ * @param {number} [now]
116
+ */
117
+ export function mergeRecentDocuments(a, b, now = Date.now()) {
118
+ return cleanRecentDocuments([...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])], now);
119
+ }
120
+
121
+ export default {
122
+ RECENT_DOCUMENTS_MAX, cleanRecentDocument, cleanRecentDocuments, countUnopenable,
123
+ noteRecentDocument, forgetRecentDocument, mergeRecentDocuments,
124
+ };
@@ -0,0 +1,47 @@
1
+ // runtime-dir.mjs — where a running kernel leaves its `kernel-<port>.json`.
2
+ // beatrina-names: keep-file — this module reads the older directory on purpose.
3
+ //
4
+ // The product's runtime directory is `~/.beatrina/run` (`BEATRINA_RUNTIME_DIR`
5
+ // names another; `CARMAR_RUNTIME_DIR` is still accepted, host/env-names.mjs).
6
+ // Until 2026-09-16 it was `~/.carmar/run`, and a kernel started by an older
7
+ // launcher, or one still running from before the rename, wrote its record
8
+ // THERE. So the two questions have two answers:
9
+ //
10
+ // · WRITE goes to one directory: the explicit one, else `~/.beatrina/run`.
11
+ // · READ (the Sessions pane, `beatrina status`/`stop`, the MCP bridge's
12
+ // discovery) covers both, the current first, so a session is never lost
13
+ // to the rename. An EXPLICIT directory is read alone: a test that
14
+ // relocates the records must not see the user's real sessions.
15
+ //
16
+ // It is deliberately not keyed on the app id (host/user-dirs.mjs says why: it
17
+ // is how every tool finds every running kernel).
18
+
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { readEnv } from "./env-names.mjs";
22
+
23
+ export const RUNTIME_DIR_CURRENT = [".beatrina", "run"];
24
+ export const RUNTIME_DIR_LEGACY = [".carmar", "run"];
25
+
26
+ /**
27
+ * @param {Record<string,string|undefined>|((name: string, unset?: string) => string)} [env]
28
+ * process.env, or a `Sys.getenv`-style getter
29
+ * @param {string} [home]
30
+ * @returns {{write: string, read: string[], explicit: boolean}}
31
+ */
32
+ export function runtimeDirs(env = process.env, home = os.homedir()) {
33
+ const explicit = readEnv(env, "RUNTIME_DIR").trim();
34
+ if (explicit) return { write: explicit, read: [explicit], explicit: true };
35
+ const current = path.join(home, ...RUNTIME_DIR_CURRENT);
36
+ return { write: current, read: [current, path.join(home, ...RUNTIME_DIR_LEGACY)], explicit: false };
37
+ }
38
+
39
+ /** The one directory a new record is written to. */
40
+ export function runtimeWriteDir(env = process.env, home = os.homedir()) {
41
+ return runtimeDirs(env, home).write;
42
+ }
43
+
44
+ /** Every directory a record may be read from, current first. */
45
+ export function runtimeReadDirs(env = process.env, home = os.homedir()) {
46
+ return runtimeDirs(env, home).read;
47
+ }
package/host/server.mjs CHANGED
@@ -31,8 +31,10 @@ import path from "node:path";
31
31
  import { attachWebSocket } from "./ws.mjs";
32
32
  import { rookOf, proxyUser } from "./deployment.mjs";
33
33
  import { cleanSessionDocuments } from "./session-keep.mjs";
34
+ import { readEnv } from "./env-names.mjs";
35
+ import { gatewayCommands, gatewayTokenGate, GATEWAY_MANAGED_COMMANDS, GATEWAY_MANAGED_PATHS, GATEWAY_HIDDEN_CAPABILITIES } from "./gateway-token.mjs";
34
36
 
35
- export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
37
+ export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "column_values", "rm", "packages", "doctor",
36
38
  "package_action", "package_help", "project_status", "project_action", "help", "wd",
37
39
  "parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
38
40
  "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
@@ -40,8 +42,6 @@ export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstat
40
42
  // below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
41
43
  // host asks it, after a restart it performed (WorkerPlane.startResume).
42
44
  "suspend"]);
43
- export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
44
- "input_reply", "debug_cmd"]);
45
45
  /** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
46
46
  export const NOT_YET = Object.freeze({
47
47
  files: "the file system", journal: "the document journal", ai: "the AI conversation store",
@@ -55,7 +55,9 @@ export const NOT_YET = Object.freeze({
55
55
  });
56
56
  export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
57
57
  // A copy of every object in the session, written to disk.
58
- "suspend", "suspend_cancel"]);
58
+ "suspend", "suspend_cancel",
59
+ // The grid's clipboard door (a whole column, up to a million values); an agent reads data through a chunk.
60
+ "column_values"]);
59
61
  /** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
60
62
  export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
61
63
  export const AGENT_WHY = Object.freeze({
@@ -63,6 +65,7 @@ export const AGENT_WHY = Object.freeze({
63
65
  project_action: "Agents cannot install or restore project packages.",
64
66
  suspend: "Agents cannot keep the user's workspace.",
65
67
  suspend_cancel: "Agents cannot keep the user's workspace.",
68
+ column_values: "Agents read data through notebook chunks (chunk_run), not the grid's copy door.",
66
69
  });
67
70
  export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
68
71
  export const FILE_ORIGIN = "null";
@@ -88,9 +91,16 @@ export function secureToken(n = 32) {
88
91
  return out;
89
92
  }
90
93
 
91
- /** Stamp the served HTML with this session's identity, once per load. */
92
- export function stampSession(html, stamp) {
93
- const meta = `<meta name="carmar-session" content="${stamp}">`;
94
+ /**
95
+ * Stamp the served HTML with this session's identity, once per load — and, for a product other
96
+ * than Beatrina (`app`, BEATRIX_APP_ID: "beatrina"), with which product served it, so the page's
97
+ * Start R can name the door this product has (docs/launch-menu-plan.md L6). A Beatrina page is the
98
+ * installed file plus the ONE session meta, exactly as before (test/host-launch.test.mjs pins that
99
+ * byte for byte); a page opened from disk carries neither, and its Start R stays Beatrina's.
100
+ */
101
+ export function stampSession(html, stamp, app = "") {
102
+ const product = String(app || "").replace(/[^A-Za-z0-9.]/g, "");
103
+ const meta = `<meta name="beatrina-session" content="${stamp}">${product && product !== "beatrina" ? `<meta name="beatrina-app" content="${product}">` : ""}`;
94
104
  let m = html.match(/<head\b[^>]*>/);
95
105
  if (!m) m = html.match(/<html\b[^>]*>/);
96
106
  if (!m) return meta + html;
@@ -100,7 +110,7 @@ export function stampSession(html, stamp) {
100
110
 
101
111
  /**
102
112
  * @param {Object} opts
103
- * @param {ReturnType<import("./deployment.mjs").carmarDeployment>} opts.deployment
113
+ * @param {ReturnType<import("./deployment.mjs").beatrinaDeployment>} opts.deployment
104
114
  * @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
105
115
  * router; it presents one WorkerPlane's surface, so everything here reads as
106
116
  * it did when there was one engine (WP4, docs/wp/wp4-python.md)
@@ -113,9 +123,15 @@ export function stampSession(html, stamp) {
113
123
  * @param {(event: string, fields?: object) => void} opts.audit
114
124
  * @param {() => void} opts.onShutdown
115
125
  * @param {string} opts.here the directory holding the fallback spike page
126
+ * @param {string} [opts.gatewayToken] private gateway credential, required on HTTP and WS
116
127
  */
117
128
  export function createHostServer(opts) {
118
129
  const { deployment, plane, audit, onShutdown, planes = [] } = opts;
130
+ const gatewayOk = gatewayTokenGate(opts.gatewayToken);
131
+ const gatewayMode = opts.gatewayToken !== undefined;
132
+ const commands = gatewayCommands(planes.flatMap((pl) => pl.commands || []), gatewayMode);
133
+ const capabilities = planes.flatMap((pl) => pl.capabilities || []).filter((capability) => !gatewayMode || !GATEWAY_HIDDEN_CAPABILITIES.includes(capability));
134
+ const managedError = "Sessions and software updates are managed by the Beatrina server. Use the server workspace controls.";
119
135
  const originsOk = deployment.origins;
120
136
  const hostsOk = deployment.hosts;
121
137
  const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
@@ -124,7 +140,19 @@ export function createHostServer(opts) {
124
140
  // The documents open in this session, as the pages last reported them (host/session-keep.mjs).
125
141
  sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
126
142
  plane.sockets = () => sockets;
127
- plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
143
+ // A TERMINAL worker death leaves the page stranded: no R, but a supervisor that still answers the
144
+ // handoff (src/r-kernel.js `stranded`). A page that connects AFTER the death — the page's own
145
+ // reconnect does exactly that — never saw a ready frame to learn this session's build or its
146
+ // doors from, so the notice carries them. Our own frame, re-encoded; never a worker's bytes.
147
+ const strandedNotice = (payload) => {
148
+ if (typeof payload !== "string" || !payload.includes('"worker-died"') || !payload.includes('"terminal":true')) return payload;
149
+ try {
150
+ const frame = JSON.parse(payload);
151
+ const doors = commands.filter((c) => c === "session_upgrade" || c === "session_restart");
152
+ return enc({ ...frame, session: { kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(), commands: doors } });
153
+ } catch { return payload; }
154
+ };
155
+ plane.on("broadcast", (payload) => { const out = strandedNotice(payload); sockets.forEach((r) => r.ws.send(out)); });
128
156
  // A restore's report is about every object in the session: pages only, never an agent or a native client.
129
157
  plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
130
158
 
@@ -226,8 +254,13 @@ export function createHostServer(opts) {
226
254
  }
227
255
 
228
256
  const server = http.createServer((req, res) => {
257
+ if (!gatewayOk(req)) return reject(res, "gateway authentication required");
229
258
  if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
230
259
  const pathname = new URL(req.url, "http://x").pathname;
260
+ if (gatewayMode && GATEWAY_MANAGED_PATHS.includes(pathname)) {
261
+ audit("server-managed-refused", { path: pathname });
262
+ return respond(res, 403, "application/json", enc({ ok: false, reason: "server-managed", error: managedError }));
263
+ }
231
264
  if (pathname === "/health") {
232
265
  if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
233
266
  const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
@@ -243,14 +276,14 @@ export function createHostServer(opts) {
243
276
  // Which build this kernel replaced, for the first 90 s of its life —
244
277
  // how a page recognises its successor. Never the page or its capability.
245
278
  ...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
246
- capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...planes.flatMap((pl) => pl.capabilities || [])],
279
+ capabilities: [...(gatewayMode ? [] : ["published-direct-v1", "published-pairing-v3", "session-restart-v1"]), ...capabilities],
247
280
  // `engines` is what a chunk may be RUN on, so a file page probing for
248
281
  // a kernel can tell a two-engine host from a one-engine one before it
249
282
  // opens a socket. It names every engine a runtime was found for, not
250
283
  // only the ones that have started (WP4; docs/wp/wp4-python.md).
251
284
  host: "beatrix",
252
285
  // Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
253
- app: opts.app || "carmar",
286
+ app: opts.app || "beatrina",
254
287
  engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
255
288
  engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
256
289
  }), { "Access-Control-Allow-Origin": "*" });
@@ -271,13 +304,17 @@ export function createHostServer(opts) {
271
304
  const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
272
305
  let html;
273
306
  try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
274
- return respond(res, 200, "text/html", stampSession(html, sessionStamp));
307
+ return respond(res, 200, "text/html", stampSession(html, sessionStamp, opts.app || "beatrina"));
275
308
  }
276
309
  return respond(res, 404, "text/plain", "not found");
277
310
  });
278
311
 
279
312
  attachWebSocket(server, {
280
313
  gate: (req) => {
314
+ if (!gatewayOk(req)) {
315
+ audit("rejected", { reason: "gateway authentication required" });
316
+ return { status: 403, body: "forbidden: gateway authentication required" };
317
+ }
281
318
  if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
282
319
  const origin = req.headers.origin;
283
320
  const approved = origin != null && approvals.has(origin);
@@ -291,13 +328,16 @@ export function createHostServer(opts) {
291
328
  const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
292
329
  user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
293
330
  sockets.push(rec);
331
+ // A pong is the transport answering: the browser's network process sends it even while the
332
+ // page's thread is busy rendering or throttled behind another window, so it counts as life.
333
+ ws.onPong(() => { rec.lastSeen = Date.now(); });
294
334
  state.everConnected = true;
295
335
  audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
296
336
  // The engine announced itself long before this page connected; replay
297
337
  // it — and every other engine that has since started, each under its own
298
338
  // `engine-ready` (host/engine-pool.mjs replay()).
299
339
  if (plane.hello) ws.send(plane.hello);
300
- if (plane.notice) ws.send(plane.notice);
340
+ if (plane.notice) ws.send(strandedNotice(plane.notice));
301
341
  if (typeof plane.replay === "function") plane.replay(ws);
302
342
  // …and a restore that finished before this page arrived (a page following a handoff).
303
343
  if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
@@ -340,6 +380,11 @@ export function createHostServer(opts) {
340
380
  if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
341
381
  rec.lastSeen = Date.now();
342
382
  const type = cmd.type;
383
+ if (gatewayMode && GATEWAY_MANAGED_COMMANDS.includes(type)) {
384
+ audit("server-managed-refused", { type });
385
+ rec.ws.send(enc({ type, ...(scalarChr(cmd.id) ? { id: cmd.id } : {}), ok: false, reason: "server-managed", error: managedError }));
386
+ return undefined;
387
+ }
343
388
 
344
389
  if (type === "hb") {
345
390
  rec.beats = true;
@@ -455,13 +500,25 @@ export function createHostServer(opts) {
455
500
  return undefined;
456
501
  }
457
502
 
458
- /** Close and forget sockets that have stopped answering (only those that beat). */
459
- const silenceMs = Math.max(0, Number(process.env.CARMAR_SOCKET_SILENCE ?? 90)) * 1000;
503
+ /** Close and forget sockets that have stopped answering (only those that beat).
504
+ *
505
+ * Silence alone used to be the verdict, and a page whose OWN thread was busy — rendering a
506
+ * large result, or throttled by the browser behind another window during a long chunk — sent
507
+ * no `hb` for 90 s and was cut off while R worked; the page then printed "Connection to the R
508
+ * kernel closed", redialled and adopted the run back. Misleading, and the owner said so
509
+ * (2026-09-17). So a page silent for half the threshold is PINGED at the protocol level: the
510
+ * browser answers a ping from its network process whatever the page's thread is doing, the
511
+ * pong refreshes `lastSeen` (onPong above), and only a socket that answers nothing — a
512
+ * sleeping laptop, a dropped link, a killed browser — reaches the threshold and is closed. */
513
+ const silenceMs = Math.max(0, Number(readEnv(process.env, "SOCKET_SILENCE", "90"))) * 1000;
460
514
  const reaper = setInterval(() => {
461
515
  if (!silenceMs || !sockets.length) return;
462
516
  const now = Date.now();
463
517
  for (const rec of [...sockets]) {
464
- if (rec.beats && now - rec.lastSeen >= silenceMs) {
518
+ if (!rec.beats) continue;
519
+ const silent = now - rec.lastSeen;
520
+ if (silent >= silenceMs / 2 && silent < silenceMs) { rec.ws.ping(); continue; }
521
+ if (silent >= silenceMs) {
465
522
  audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
466
523
  const at = sockets.indexOf(rec);
467
524
  if (at >= 0) sockets.splice(at, 1);
@@ -479,8 +536,8 @@ export function createHostServer(opts) {
479
536
  sessionStamp,
480
537
  ctx,
481
538
  /** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
482
- commands: planes.flatMap((pl) => pl.commands || []),
483
- capabilities: planes.flatMap((pl) => pl.capabilities || []),
539
+ commands,
540
+ capabilities,
484
541
  close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
485
542
  };
486
543
  }
@@ -7,7 +7,7 @@
7
7
  // keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
8
8
  //
9
9
  // Transcribed rule for rule from spike/workspace-keep.R (keep_token_ok) and spike/session-documents.R
10
- // (session_documents_clean/label/encode/decode), which CarmaR's R supervisor sources; pinned against
10
+ // (session_documents_clean/label/encode/decode), which Beatrina's R supervisor sources; pinned against
11
11
  // the same cases by test/session-keep.test.mjs.
12
12
 
13
13
  import path from "node:path";