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
package/bin/pages.mjs ADDED
@@ -0,0 +1,81 @@
1
+ // pages.mjs — a session's notebook page outlives an upgrade until the session restarts.
2
+ //
3
+ // The host serves beatrina_V<kernel_build>.html from beside kernel/ on every GET / (host/main.mjs
4
+ // notebookPage). `npm install -g beatrina@<next>` replaces the package folder wholesale, so the
5
+ // only page on disk is the new version's and a session still running the old one answers GET /
6
+ // with the development index ("Beatrina kernel spike") — verified live 2026-09-16 (docs/launch-menu-plan.md
7
+ // L4). Beatrina's installer kept the outgoing page beside the new one for exactly this reason
8
+ // (tools/app/launch.sh); npm does not, so the `beatrina` command keeps the copy itself:
9
+ //
10
+ // · before it starts the host, the page it is about to serve is copied ONCE per version into
11
+ // <R_user_dir(app, "cache")>/pages/ (2.6 MB; a .part file renamed into place, so a half-written
12
+ // copy is never served), and the host reads that folder as its third location;
13
+ // · `beatrina stop` and every start prune the pages of versions no live session runs and that are
14
+ // not the installed one — a cache, so nothing is said about it.
15
+ //
16
+ // Pure: the folder is passed in (host/user-dirs.mjs rUserDir is beside the package, not the checkout —
17
+ // identity.mjs's rule), so the tests drive it with a scratch folder.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ const PAGE_RE = /^beatrina_V(.+)\.html$/;
23
+
24
+ /** The page file a build serves: `beatrina_V<version>.html`. */
25
+ export const pageName = (version) => `beatrina_V${version}.html`;
26
+
27
+ /** The version a cached page file names, or "" when the name is not a page's. */
28
+ export const pageVersion = (name) => (PAGE_RE.exec(name) || [])[1] || "";
29
+
30
+ /** Where the kept pages live: `<cache dir of the app>/pages`. `dirOf` is rUserDir. */
31
+ export const pagesDir = (app, dirOf) => path.join(dirOf(app, "cache"), "pages");
32
+
33
+ /**
34
+ * Keep a copy of `from` in `dir` under its own name, unless one of the same size is already there.
35
+ * @returns {{outcome: "kept"|"present"|"absent"|"failed", path: string, detail?: string}}
36
+ */
37
+ export function keepPage({ from, dir }) {
38
+ const to = path.join(dir, path.basename(from));
39
+ let size = -1;
40
+ try { size = fs.statSync(from).size; } catch { return { outcome: "absent", path: to }; }
41
+ try { if (fs.statSync(to).size === size) return { outcome: "present", path: to }; } catch { /* not there yet */ }
42
+ const part = `${to}.part`;
43
+ try {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ fs.copyFileSync(from, part);
46
+ fs.renameSync(part, to);
47
+ return { outcome: "kept", path: to };
48
+ } catch (e) {
49
+ try { fs.rmSync(part, { force: true }); } catch { /* nothing to clear */ }
50
+ return { outcome: "failed", path: to, detail: e.message };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Remove every kept page whose version is in neither `keep` nor `installed`. A `.part` left by an
56
+ * interrupted copy goes too. @returns {string[]} the paths removed
57
+ */
58
+ export function prunePages({ dir, installed, keep = [] }) {
59
+ const wanted = new Set([installed, ...keep].filter(Boolean));
60
+ let names = [];
61
+ try { names = fs.readdirSync(dir); } catch { return []; }
62
+ const removed = [];
63
+ for (const name of names) {
64
+ const version = pageVersion(name.replace(/\.part$/, ""));
65
+ if (!version || (wanted.has(version) && !name.endsWith(".part"))) continue;
66
+ try { fs.unlinkSync(path.join(dir, name)); removed.push(path.join(dir, name)); } catch { /* in use, or gone */ }
67
+ }
68
+ return removed;
69
+ }
70
+
71
+ /**
72
+ * The versions the kept pages must keep serving: what `beatrina status` rows say each LIVE session
73
+ * runs. A live row that cannot say its build (an older kernel) keeps everything — pruning on a
74
+ * guess would take a page from under a running session.
75
+ * @returns {string[]|null} null when nothing may be pruned
76
+ */
77
+ export function versionsInUse(rows) {
78
+ const live = rows.filter((r) => r.live);
79
+ if (live.some((r) => !r.kernel_build)) return null;
80
+ return [...new Set(live.map((r) => r.kernel_build))];
81
+ }
@@ -0,0 +1,207 @@
1
+ // python-setup.mjs — `beatrina python setup`: the interpreter Beatrina manages.
2
+ //
3
+ // Discovery (host/engine-python.mjs detectPython) can only find a Python that
4
+ // already has ipykernel, and on most machines none does — the interpreter is
5
+ // there, the kernel package is not, and the Python engine silently sits out.
6
+ // This command builds the answer discovery looks for FIRST: a virtual
7
+ // environment at `~/.beatrina/python`, created from the best system Python
8
+ // 3.9 or newer the ladder itself would consider, with ipykernel installed
9
+ // (and pandas + matplotlib unless --minimal). Idempotent: an existing, working
10
+ // environment is kept and only the packages are (re)installed, which pip
11
+ // answers with "Requirement already satisfied".
12
+ //
13
+ // Every step is said as it happens, the final line is the interpreter's path,
14
+ // and the one refusal is one sentence. No host import: like cli.mjs this file
15
+ // is tested in the source tree, where `../host/` does not exist; the caller
16
+ // hands in the ladder's candidates.
17
+
18
+ import { spawn, spawnSync } from "node:child_process";
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+
22
+ /** The oldest Python `beatrina python setup` will build from. ipykernel 6.x/7.x need 3.9+. */
23
+ export const MIN_PYTHON = Object.freeze([3, 9]);
24
+ /** What the kernel needs; what a notebook usually wants on top. */
25
+ export const MINIMAL_PACKAGES = Object.freeze(["ipykernel"]);
26
+ export const DEFAULT_PACKAGES = Object.freeze(["ipykernel", "pandas", "matplotlib"]);
27
+ /** How long asking a candidate what it is may take. */
28
+ export const DESCRIBE_TIMEOUT_MS = 8000;
29
+
30
+ /** Where the managed environment lives: `~/.beatrina/python`. */
31
+ export function managedPythonDir(home) {
32
+ return path.join(home, ".beatrina", "python");
33
+ }
34
+
35
+ /**
36
+ * The interpreter inside the managed venv. The same spelling as
37
+ * host/engine-python.mjs `venvInterpreter` — `bin/python3`, or
38
+ * `Scripts\python.exe` on Windows — restated here so this file needs no host import.
39
+ */
40
+ export function managedInterpreter(home, platform = process.platform) {
41
+ const dir = managedPythonDir(home);
42
+ return platform === "win32" ? path.win32.join(dir, "Scripts", "python.exe") : path.join(dir, "bin", "python3");
43
+ }
44
+
45
+ /** "3.14.4" → [3, 14, 4]; anything unparseable → null. */
46
+ export function parseVersion(text) {
47
+ const m = String(text ?? "").trim().match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
48
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : null;
49
+ }
50
+
51
+ /** Is `version` (a string or a triple) at least MIN_PYTHON? */
52
+ export function meetsMinimum(version) {
53
+ const v = Array.isArray(version) ? version : parseVersion(version);
54
+ if (!v) return false;
55
+ return v[0] > MIN_PYTHON[0] || (v[0] === MIN_PYTHON[0] && v[1] >= MIN_PYTHON[1]);
56
+ }
57
+
58
+ const DESCRIBE_SCRIPT = "import json,sys\n"
59
+ + "out={'version':'.'.join(str(x) for x in sys.version_info[:3]),'executable':sys.executable,"
60
+ + "'base':sys.prefix==getattr(sys,'base_prefix',sys.prefix),'venv_ok':True,'has_kernel':True}\n"
61
+ + "for m in ('venv','ensurepip'):\n"
62
+ + " try:\n __import__(m)\n except Exception:\n out['venv_ok']=False\n"
63
+ + "for m in ('ipykernel','jupyter_client'):\n"
64
+ + " try:\n __import__(m)\n except Exception:\n out['has_kernel']=False\n"
65
+ + "print(json.dumps(out))\n";
66
+
67
+ /**
68
+ * Ask one interpreter what it is: its version, whether it is a base install
69
+ * (not itself a venv), whether it can make a venv, whether it already has the
70
+ * kernel. `null` when it does not answer.
71
+ * @returns {{version: string, executable: string, base: boolean, venv_ok: boolean, has_kernel: boolean}|null}
72
+ */
73
+ export function describePython(bin, { timeoutMs = DESCRIBE_TIMEOUT_MS } = {}) {
74
+ const r = spawnSync(bin, ["-c", DESCRIBE_SCRIPT], { encoding: "utf8", timeout: timeoutMs });
75
+ if (r.error || r.status !== 0) return null;
76
+ try { return JSON.parse(String(r.stdout).trim().split("\n").pop()); } catch { return null; }
77
+ }
78
+
79
+ /** Is `file` inside `dir`? */
80
+ function within(file, dir) {
81
+ const rel = path.relative(dir, file);
82
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
83
+ }
84
+
85
+ /**
86
+ * Pick the Python to build the environment from.
87
+ *
88
+ * The ladder's own candidates, in the ladder's order, minus the managed
89
+ * environments themselves (an environment cannot be built from the one being
90
+ * built). Each existing candidate is asked what it is; the first BASE install
91
+ * of 3.9+ that can make a venv wins, and failing that the first venv of 3.9+
92
+ * that can — a venv's `python -m venv` builds from its base interpreter, so
93
+ * that still works, it is just a less direct answer.
94
+ *
95
+ * @param {object} opts
96
+ * @param {Array<{path: string, why: string}>} opts.candidates from host/engine-python.mjs pythonCandidates
97
+ * @param {string} opts.home
98
+ * @param {(bin: string) => object|null} [opts.describe]
99
+ * @param {(p: string) => boolean} [opts.exists]
100
+ * @returns {{chosen: {path: string, why: string, version: string}|null, considered: Array<object>}}
101
+ */
102
+ export function chooseBasePython({ candidates, home, describe = describePython, exists = (p) => fs.existsSync(p) }) {
103
+ const managed = [path.join(home, ".beatrina", "python")];
104
+ const considered = [];
105
+ const usable = [];
106
+ for (const c of candidates) {
107
+ if (managed.some((dir) => within(c.path, dir))) continue;
108
+ if (!exists(c.path)) continue;
109
+ const info = describe(c.path);
110
+ if (!info) { considered.push({ ...c, detail: "did not answer" }); continue; }
111
+ const ok = meetsMinimum(info.version) && info.venv_ok;
112
+ considered.push({ ...c, version: info.version, base: Boolean(info.base), ok,
113
+ detail: !meetsMinimum(info.version) ? `${info.version}, older than ${MIN_PYTHON.join(".")}` : !info.venv_ok ? `${info.version}, no venv module` : info.version });
114
+ if (ok) usable.push({ path: c.path, why: c.why, version: info.version, base: Boolean(info.base) });
115
+ // A candidate list is a PATH of dozens of directories; ten answers is a search.
116
+ if (considered.length >= 12) break;
117
+ }
118
+ const chosen = usable.find((u) => u.base) || usable[0] || null;
119
+ return { chosen: chosen ? { path: chosen.path, why: chosen.why, version: chosen.version } : null, considered };
120
+ }
121
+
122
+ /** The one sentence said when nothing can be built from. */
123
+ export const NO_PYTHON_REFUSAL = `No Python ${MIN_PYTHON.join(".")} or newer with the venv module was found on this machine; `
124
+ + "install Python from https://www.python.org/downloads/ and run `beatrina python setup` again.";
125
+
126
+ /** Run a program, handing every output line to `log`; resolves to its exit status. */
127
+ export function runLogged(bin, args, { log = console.log, env = process.env, cwd } = {}) {
128
+ return new Promise((resolve) => {
129
+ const child = spawn(bin, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
130
+ let rest = { out: "", err: "" };
131
+ const feed = (key) => (chunk) => {
132
+ rest[key] += String(chunk);
133
+ const lines = rest[key].split(/\r?\n/);
134
+ rest[key] = lines.pop();
135
+ for (const line of lines) if (line.trim()) log(` ${line}`);
136
+ };
137
+ child.stdout.on("data", feed("out"));
138
+ child.stderr.on("data", feed("err"));
139
+ child.on("error", (e) => { log(` ${e.message}`); resolve(127); });
140
+ child.on("close", (code) => {
141
+ for (const key of ["out", "err"]) if (rest[key].trim()) log(` ${rest[key]}`);
142
+ resolve(code ?? 1);
143
+ });
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Create or refresh `~/.beatrina/python`.
149
+ *
150
+ * @param {object} opts
151
+ * @param {string} opts.home the user's home (HOME; tests point it at a scratch dir)
152
+ * @param {Array<{path: string, why: string}>} opts.candidates the ladder's candidates for this env
153
+ * @param {boolean} [opts.minimal] ipykernel only
154
+ * @param {(line: string) => void} [opts.log]
155
+ * @param {Record<string, string|undefined>} [opts.env] the environment for the child processes
156
+ * @param {string} [opts.platform]
157
+ * @param {typeof describePython} [opts.describe]
158
+ * @param {typeof runLogged} [opts.run]
159
+ * @param {(p: string) => boolean} [opts.exists] which candidates exist (tests)
160
+ * @returns {Promise<{ok: true, interpreter: string, version: string, created: boolean, packages: string[]}
161
+ * | {ok: false, refusal: string}>}
162
+ */
163
+ export async function setupManagedPython({ home, candidates, minimal = false, log = console.log, env = process.env,
164
+ platform = process.platform, describe = describePython, run = runLogged, exists = (p) => fs.existsSync(p) } = {}) {
165
+ if (!home) return { ok: false, refusal: "beatrina python setup needs a home directory (HOME is unset)." };
166
+ const dir = managedPythonDir(home);
167
+ const interpreter = managedInterpreter(home, platform);
168
+ const packages = [...(minimal ? MINIMAL_PACKAGES : DEFAULT_PACKAGES)];
169
+ // Python's own variables would aim the new environment at another install.
170
+ const childEnv = Object.fromEntries(Object.entries(env).filter(([k]) => !/^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE|VIRTUAL_ENV)$/.test(k)));
171
+
172
+ log(`Looking for a Python ${MIN_PYTHON.join(".")} or newer to build from…`);
173
+ const { chosen, considered } = chooseBasePython({ candidates, home, describe, exists });
174
+ for (const c of considered) log(` ${c.ok ? "usable" : "not usable"}: ${c.path} — ${c.detail} (${c.why})`);
175
+ if (!chosen) return { ok: false, refusal: NO_PYTHON_REFUSAL };
176
+ log(`Using Python ${chosen.version} at ${chosen.path} (${chosen.why}).`);
177
+
178
+ let created = false;
179
+ const existing = fs.existsSync(interpreter) ? describe(interpreter) : null;
180
+ if (existing) {
181
+ log(`${dir} exists (Python ${existing.version}) — keeping it.`);
182
+ } else {
183
+ const rebuild = fs.existsSync(dir);
184
+ log(rebuild ? `${dir} exists but its interpreter does not answer — rebuilding it…` : `Creating the virtual environment at ${dir}…`);
185
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
186
+ const status = await run(chosen.path, ["-m", "venv", ...(rebuild ? ["--clear"] : []), dir], { log, env: childEnv });
187
+ if (status !== 0 || !fs.existsSync(interpreter)) {
188
+ return { ok: false, refusal: `python -m venv ${dir} failed (exit ${status}); the messages above say why.` };
189
+ }
190
+ created = true;
191
+ }
192
+
193
+ log(`Installing ${packages.join(", ")} into it (pip)…`);
194
+ const pipStatus = await run(interpreter, ["-m", "pip", "install", "--upgrade", "--disable-pip-version-check", ...packages], { log, env: childEnv });
195
+ if (pipStatus !== 0) {
196
+ return { ok: false, refusal: `pip could not install ${packages.join(", ")} (exit ${pipStatus}) — the messages above say why, usually no network; `
197
+ + `${dir} is kept, run \`beatrina python setup\` again when it is back.` };
198
+ }
199
+
200
+ const after = describe(interpreter);
201
+ if (!after || !after.has_kernel) {
202
+ return { ok: false, refusal: `${interpreter} still cannot import ipykernel after the install; the messages above say why.` };
203
+ }
204
+ log(`Python for Beatrina: ${interpreter} (Python ${after.version}, ${packages.join(", ")}).`);
205
+ log("Beatrina looks here first from now on; `beatrina doctor` shows it.");
206
+ return { ok: true, interpreter, version: after.version, created, packages };
207
+ }
@@ -0,0 +1,27 @@
1
+ // runtime-dirs.mjs — where a session's runtime record is written and read, for the CLI.
2
+ // beatrina-names: keep-file — this module reads the older directory and variable on purpose.
3
+ //
4
+ // The rule is host/runtime-dir.mjs's, restated: this module is imported from the checkout
5
+ // too (test/beatrina-sessions, test/npm-shortcut), where `../host` does not exist — the same
6
+ // reason identity.mjs takes `dirOf` as a parameter. test/runtime-dirs.test.mjs pins that the
7
+ // two modules answer identically, so the restatement cannot drift.
8
+ //
9
+ // write: ~/.beatrina/run
10
+ // read: ~/.beatrina/run, then ~/.carmar/run (a session an older build started)
11
+ // an explicit BEATRINA_RUNTIME_DIR (or the old CARMAR_RUNTIME_DIR) is read ALONE, so a test's
12
+ // scratch directory never sees the user's real sessions.
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ const explicitDir = (env) => String(env.BEATRINA_RUNTIME_DIR ?? env.CARMAR_RUNTIME_DIR ?? "").trim();
18
+
19
+ /** @returns {{write: string, read: string[], explicit: boolean}} */
20
+ export function runtimeDirs(env = process.env, home = os.homedir()) {
21
+ const explicit = explicitDir(env);
22
+ if (explicit) return { write: explicit, read: [explicit], explicit: true };
23
+ const current = path.join(home, ".beatrina", "run");
24
+ return { write: current, read: [current, path.join(home, ".carmar", "run")], explicit: false };
25
+ }
26
+ export const runtimeWriteDir = (env = process.env, home = os.homedir()) => runtimeDirs(env, home).write;
27
+ export const runtimeReadDirs = (env = process.env, home = os.homedir()) => runtimeDirs(env, home).read;
package/bin/sessions.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // sessions.mjs — `beatrina status` and `beatrina stop`.
2
2
  //
3
- // A running session writes ~/.carmar/run/kernel-<port>.json (host/main.mjs, 0600) and removes it on a
3
+ // A running session writes ~/.beatrina/run/kernel-<port>.json (host/main.mjs, 0600) and removes it on a
4
4
  // clean exit. A record whose /health does not answer is one a session left behind (a crash, a closed
5
5
  // laptop). Status lists both kinds and says which is which; stop asks a live session to shut down
6
6
  // through POST /shutdown (the same door Session ▸ Quit uses; loopback, no Origin = this user), waits
@@ -12,8 +12,14 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { APP_ID, LEGACY_APP_ID } from "./identity.mjs";
14
14
 
15
- // Where host/main.mjs writes its records; CARMAR_RUNTIME_DIR relocates it (tests), as it does for the host.
16
- export const runtimeDir = (env = process.env) => env.CARMAR_RUNTIME_DIR || path.join(os.homedir(), ".carmar", "run");
15
+ // beatrina-names: keep-start
16
+ // Where host/main.mjs writes its records (~/.beatrina/run since 2026-09-17; ~/.carmar/run before, still
17
+ // READ so a session started by older code or the pip package's legacy scan is not lost). An explicit
18
+ // BEATRINA_RUNTIME_DIR / CARMAR_RUNTIME_DIR relocates it (tests), as it does for the host.
19
+ // beatrina-names: keep-end
20
+ import { runtimeReadDirs, runtimeWriteDir } from "./runtime-dirs.mjs";
21
+ export const runtimeDir = (env = process.env) => runtimeWriteDir(env);
22
+ export const runtimeDirsToRead = (env = process.env) => runtimeReadDirs(env);
17
23
  const APPS = new Set([APP_ID, LEGACY_APP_ID]);
18
24
 
19
25
  async function health(port, timeoutMs = 1500) {
@@ -29,21 +35,35 @@ export const sameSession = (h, rec) => Boolean(h && h.host === rec.host
29
35
  && Number.isFinite(Number(h.started)) && Math.abs(Number(h.started) * 1000 - Date.parse(rec.started)) < 1000);
30
36
 
31
37
  /** Every Beatrina session this user has a record of, live or left behind, by port. */
32
- export async function listSessions({ dir = runtimeDir(), apps = APPS } = {}) {
33
- let names = [];
34
- try { names = fs.readdirSync(dir).filter((n) => /^kernel-\d+\.json$/.test(n)); } catch { return []; }
35
- const rows = await Promise.all(names.map(async (name) => {
36
- const record = path.join(dir, name);
38
+ export async function listSessions({ dir = null, dirs = dir ? [dir] : runtimeDirsToRead(), apps = APPS } = {}) {
39
+ // Current directory first; a port seen there hides the same port in a legacy directory.
40
+ const records = new Map();
41
+ for (const d of dirs) {
42
+ let names = [];
43
+ try { names = fs.readdirSync(d).filter((n) => /^kernel-\d+\.json$/.test(n)); } catch { continue; }
44
+ for (const name of names) if (!records.has(name)) records.set(name, path.join(d, name));
45
+ }
46
+ const rows = await Promise.all([...records.values()].map(async (record) => {
37
47
  let rec;
38
48
  try { rec = JSON.parse(fs.readFileSync(record, "utf8")); } catch { return null; }
39
49
  if (!apps.has(rec.app)) return null;
40
50
  const h = await health(rec.port);
41
51
  const live = sameSession(h, rec);
42
- return { port: Number(rec.port), pid: rec.pid, started: rec.started || "", document: rec.document || "", url: rec.url || "", file: rec.file || "", app: rec.app, live, record };
52
+ // `documents` is the session's own label of what it holds ("a.qmd + 1 more", host/main.mjs
53
+ // onSessionDocuments); `document` is what it was started with. `kernel_build` / `installed_build`
54
+ // are /health's, present only while the session answers: which version it runs, and which is on
55
+ // disk beside it — a session outlives an upgrade until it restarts.
56
+ return { port: Number(rec.port), pid: rec.pid, started: rec.started || "", document: rec.document || "", documents: rec.documents || "",
57
+ title: rec.title || "", url: rec.url || "", file: rec.file || "", app: rec.app, host: rec.host || "", live, record,
58
+ kernel_build: live && typeof h.kernel_build === "string" ? h.kernel_build : "",
59
+ installed_build: live && typeof h.installed_build === "string" ? h.installed_build : "" };
43
60
  }));
44
61
  return rows.filter(Boolean).sort((a, b) => a.port - b.port);
45
62
  }
46
63
 
64
+ /** What a row is called by: the session's own documents label when it has one, else what it was started with. */
65
+ export const documentLabel = (r) => r.documents || r.document || "";
66
+
47
67
  /** The status table as lines. */
48
68
  export function formatSessions(rows, now = Date.now()) {
49
69
  if (!rows.length) return ["No Beatrina session is running."];
@@ -52,7 +72,7 @@ export function formatSessions(rows, now = Date.now()) {
52
72
  return Number.isFinite(s) ? (s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : `${(s / 3600).toFixed(1)}h`) : "?";
53
73
  };
54
74
  const table = [["PORT", "PID", "UP", "STATE", "DOCUMENT"],
55
- ...rows.map((r) => [String(r.port), String(r.pid ?? ""), r.started ? age(r.started) : "?", r.live ? "running" : "not answering (left behind)", r.document || "—"])];
75
+ ...rows.map((r) => [String(r.port), String(r.pid ?? ""), r.started ? age(r.started) : "?", r.live ? "running" : "not answering (left behind)", documentLabel(r) || "—"])];
56
76
  const widths = table[0].map((_, i) => Math.max(...table.map((row) => row[i].length)));
57
77
  return table.map((row) => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
58
78
  }