beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
package/host/main.mjs ADDED
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env node
2
+ // main.mjs — `beatrix`: start the host on a loopback port and print where it is.
3
+ //
4
+ // Prints ONE line of JSON on stdout — {"url": ..., "file": ...} — exactly as
5
+ // spike/serve.R does, so every launcher, fixture and agent that reads that
6
+ // line works unchanged. Everything else goes to stderr.
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).
17
+ //
18
+ // PACKAGING (docs/packaging.md). Node's Single Executable Application runs its
19
+ // main script as CommonJS — `embedderRunCjs`, measured on Node 25.5.0, an ESM
20
+ // blob dies with "Cannot use import statement outside a module" — so this file
21
+ // must survive `esbuild --format=cjs`. That costs three rules, and they are why
22
+ // the body below is a function rather than a script:
23
+ // * no top-level `await` (esbuild refuses it outright in cjs output),
24
+ // * no bare `import.meta.url` on a path a packaged binary walks, because cjs
25
+ // output leaves it empty — `moduleDir()` is the one place that reads it, and
26
+ // * the planes are STATIC imports, not `await import(variable)`: a bundler
27
+ // cannot follow a dynamic specifier, so a packaged binary would boot with
28
+ // no planes at all and refuse every family by name.
29
+
30
+ import fs from "node:fs";
31
+ import { spawn } from "node:child_process";
32
+ import net from "node:net";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import sea from "node:sea";
36
+ import { fileURLToPath } from "node:url";
37
+ import { carmarDeployment } from "./deployment.mjs";
38
+ import { REngine, detectRscript } from "./engine-r.mjs";
39
+ import { PythonEngine, detectPython } from "./engine-python.mjs";
40
+ import { JsEngine, detectNode } from "./engine-js.mjs";
41
+ import { EnginePool } from "./engine-pool.mjs";
42
+ import { createHostServer, secureToken } from "./server.mjs";
43
+ import { createSettings } from "./settings.mjs";
44
+ // ── planes (one line per family; see the seam in server.mjs) ───────────────
45
+ // Each work package registers its module here and nowhere else. Keep the
46
+ // list alphabetical by file so two packages landing at once merge cleanly.
47
+ // Static imports on purpose — see PACKAGING above.
48
+ import * as planeAiStore from "./planes/ai-store.mjs";
49
+ import * as planeAi from "./planes/ai.mjs";
50
+ import * as planeAnalyze from "./planes/analyze.mjs";
51
+ import * as planeCite from "./planes/cite.mjs";
52
+ import * as planeLatex from "./planes/latex.mjs";
53
+ import * as planeFiles from "./planes/files.mjs";
54
+ import * as planeJobs from "./planes/jobs.mjs";
55
+ import * as planeJournal from "./planes/journal.mjs";
56
+ import * as planeMcp from "./planes/mcp.mjs";
57
+ import * as planePair from "./planes/pair.mjs";
58
+ import * as planePlugins from "./planes/plugins.mjs";
59
+ import * as planeSessions from "./planes/sessions.mjs";
60
+ import * as planeSettings from "./planes/settings.mjs";
61
+ import * as planeTerminal from "./planes/terminal.mjs";
62
+ import * as planeTestFile from "./planes/test-file.mjs";
63
+ import * as planeUpdate from "./planes/update.mjs";
64
+ import { appId } from "./user-dirs.mjs";
65
+ 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];
67
+
68
+ const env = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
69
+
70
+ /** Whether this process is a packaged single executable, asked once. */
71
+ const packaged = (() => { try { return sea.isSea(); } catch { return false; } })();
72
+
73
+ /** This module's own directory — "" when the cjs bundler emptied import.meta. */
74
+ function moduleDir() {
75
+ const u = import.meta.url;
76
+ return typeof u === "string" && u.startsWith("file:") ? path.dirname(fileURLToPath(u)) : "";
77
+ }
78
+
79
+ /**
80
+ * Where the host's own files live.
81
+ *
82
+ * 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/.
86
+ */
87
+ function hostRoot() {
88
+ const dictated = env("CARMAR_HOST_ROOT").trim();
89
+ if (dictated) return path.resolve(dictated);
90
+ if (packaged) return path.dirname(process.execPath);
91
+ const mine = moduleDir();
92
+ if (mine) return path.resolve(mine, "..");
93
+ return path.dirname(process.execPath);
94
+ }
95
+
96
+ /**
97
+ * The directory holding the R kernel scripts and the build stamps.
98
+ *
99
+ * `spike/` in the repo; `kernel/` in every distribution, because that is what
100
+ * 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.
102
+ */
103
+ function kernelDir(base) {
104
+ const dictated = env("CARMAR_KERNEL_DIR").trim();
105
+ if (dictated) return path.resolve(dictated);
106
+ for (const name of ["spike", "kernel"]) {
107
+ if (fs.existsSync(path.join(base, name, "worker-boot.R"))) return path.join(base, name);
108
+ }
109
+ return path.join(base, "spike");
110
+ }
111
+
112
+ const root = hostRoot();
113
+ const spike = kernelDir(root);
114
+
115
+ // ── identity: protocol and build, read the way serve.R reads them ──────────
116
+ function readProtocol() {
117
+ for (const p of [path.join(spike, "kernel-protocol"), path.join(root, "kernel.protocol")]) {
118
+ if (fs.existsSync(p)) {
119
+ const v = Number.parseInt(fs.readFileSync(p, "utf8").trim(), 10);
120
+ if (Number.isInteger(v) && v >= 1) return v;
121
+ throw new Error("Beatrina: the kernel protocol stamp is invalid.");
122
+ }
123
+ }
124
+ throw new Error("Beatrina: the kernel protocol stamp is missing.");
125
+ }
126
+ function sourceVersion() {
127
+ const f = path.join(root, "notebook.version.cjs");
128
+ if (!fs.existsSync(f)) return "unknown";
129
+ const m = fs.readFileSync(f, "utf8").match(/version\s*:\s*"([^"]+)"/);
130
+ return m ? m[1] : "unknown";
131
+ }
132
+ function readKernelBuild() {
133
+ const dictated = env("CARMAR_KERNEL_BUILD").trim();
134
+ if (dictated) return dictated;
135
+ const stamp = path.join(spike, "kernel-version");
136
+ if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
137
+ return sourceVersion();
138
+ }
139
+ function installedBuild() {
140
+ const dictated = env("CARMAR_KERNEL_BUILD").trim();
141
+ if (dictated) return env("CARMAR_INSTALLED_BUILD").trim() || dictated;
142
+ const stamp = path.join(spike, "kernel-version");
143
+ if (fs.existsSync(stamp)) { const v = fs.readFileSync(stamp, "utf8").trim(); if (v) return v; }
144
+ return sourceVersion();
145
+ }
146
+ /**
147
+ * The ONE page a kernel of a given build serves: a pin, never a search.
148
+ *
149
+ * `spike/index.html` is the fallback, exactly as `notebook_page()` in
150
+ * spike/serve.R has one: a source checkout with nothing built still ANNOUNCES
151
+ * a page, and the suites that wait for that announcement (anything through
152
+ * test/fixtures/ai-e2e.mjs, which resolves on the `file` field) work without a
153
+ * build on both supervisors. Returning "" here made them time out on the host
154
+ * alone, which is a difference in the fixture's eyes and not in the design's.
155
+ */
156
+ function notebookPage(build, { fallback = true } = {}) {
157
+ const wanted = `carmar_V${build}.html`;
158
+ // serve.R's pin (spike/notebook-page.R): beside the KERNEL directory, not the
159
+ // 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);
162
+ if (!fallback) return "";
163
+ const dev = path.join(spike, "index.html");
164
+ return fs.existsSync(dev) ? dev : "";
165
+ }
166
+ /** "4.5.2" out of "R version 4.5.2 (2025-10-31)" — the only kernel that sends
167
+ * a version without a `language_version` field is R, which sends this. */
168
+ function rVersionOf(frame) {
169
+ const m = typeof frame.r === "string" ? frame.r.match(/R version (\S+)/) : null;
170
+ return m ? m[1] : "";
171
+ }
172
+
173
+ function notebookFileUrl(p, port, cap) {
174
+ const slashed = p.replace(/\\/g, "/");
175
+ const prefix = slashed.startsWith("/") ? "file://" : "file:///";
176
+ return `${prefix}${encodeURI(slashed)}#kernel=${port}${cap ? `&pair=${cap}` : ""}`;
177
+ }
178
+
179
+ // ── the port: a preference by default, a promise when asked ────────────────
180
+ const portTaken = (p) => new Promise((resolve) => {
181
+ const s = net.connect({ port: p, host: "127.0.0.1" });
182
+ s.setTimeout(1000);
183
+ s.once("connect", () => { s.destroy(); resolve(true); });
184
+ s.once("error", () => resolve(false));
185
+ s.once("timeout", () => { s.destroy(); resolve(false); });
186
+ });
187
+ async function choosePort() {
188
+ let wanted = Number.parseInt(env("CARMAR_PORT", "4747"), 10);
189
+ if (!Number.isInteger(wanted) || wanted < 1024 || wanted > 65535) wanted = 4747;
190
+ if (!(await portTaken(wanted))) return wanted;
191
+ const waitS = Number(env("CARMAR_WAIT_PORT", "0"));
192
+ if (Number.isFinite(waitS) && waitS > 0) {
193
+ const deadline = Date.now() + waitS * 1000;
194
+ while (Date.now() < deadline) { // a wait is a loop
195
+ await new Promise((r) => setTimeout(r, 250));
196
+ if (!(await portTaken(wanted))) return wanted;
197
+ }
198
+ }
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.`);
201
+ process.exit(2);
202
+ }
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;
205
+ return 0;
206
+ }
207
+
208
+ // The whole start-up is a FUNCTION, not a script: Node SEA runs its main as
209
+ // CommonJS and esbuild refuses top-level await in cjs output (docs/packaging.md).
210
+ // Nothing below may move back out to the module body.
211
+ async function main() {
212
+ const protocol = readProtocol();
213
+ const kernelBuild = readKernelBuild();
214
+ // 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.
216
+ const newerOnNpm = () => updateAvailable({ file: updateRecordFile(appId()), current: kernelBuild });
217
+ // WP5a — the user's settings, and the administrator's managed policy.
218
+ // Created HERE, before the port and the deployment, because reading the managed
219
+ // 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
221
+ // env-wins-and-locks rule is only a single source of truth if the environment
222
+ // is already complete when the first subsystem reads it. Its errors refuse
223
+ // startup for the deployment's reason: a governance control that silently
224
+ // failed to apply is worse than one that does not come up.
225
+ // Which package's folders hold this host's data (host/user-dirs.mjs appId).
226
+ // Asked first: every store below keys on it, and a malformed value would
227
+ // otherwise surface as a crash inside whichever store touched it first.
228
+ try { appId(); } catch (e) {
229
+ console.error(`Beatrina refuses to start: ${e.message}`);
230
+ process.exit(2);
231
+ }
232
+ const settings = createSettings({ env });
233
+ if (settings.errors.length) {
234
+ for (const m of settings.errors) console.error(`Beatrina refuses to start: ${m}`);
235
+ process.exit(2);
236
+ }
237
+ const port = await choosePort();
238
+ const deployment = carmarDeployment(port, env);
239
+ if (deployment.errors.length) {
240
+ for (const m of deployment.errors) console.error(`Beatrina refuses to start: ${m}`);
241
+ process.exit(2);
242
+ }
243
+
244
+ // ── the audit log ──────────────────────────────────────────────────────────
245
+ const logPath = env("CARMAR_LOG");
246
+ const audit = (event, fields = {}) => {
247
+ if (!logPath) return;
248
+ const rec = { ts: new Date().toISOString(), pid: process.pid, event, ...fields };
249
+ try { fs.appendFileSync(logPath, `${JSON.stringify(rec)}\n`); } catch { /* logging must never take the host down */ }
250
+ };
251
+
252
+ // ── the engines and the pool ───────────────────────────────────────────────
253
+ // R is spawned at boot: the page will not leave "connecting" until a `ready`
254
+ // arrives. Every other engine starts the first time a chunk of its language
255
+ // runs (docs/host-plan.md §1) — an ipykernel nobody asked for is 1.3 seconds
256
+ // and tens of megabytes spent on a document that may hold no Python at all.
257
+ const workerPath = path.join(spike, "worker-boot.R");
258
+ 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;
261
+ const childEnv = {};
262
+ if (env("CARMAR_ROOT")) childEnv.CARMAR_ROOT = env("CARMAR_ROOT");
263
+ // share()/use(): the one explicit bridge between the session's languages.
264
+ // One 0700 directory per host, shared by every engine it starts, removed at
265
+ // exit. A value crosses only when a chunk names it (engines/js/worker.mjs,
266
+ // engines/r/handoff.R); nothing is ever copied implicitly.
267
+ const handoffDir = fs.mkdtempSync(path.join(os.tmpdir(), "beatrix-handoff-"));
268
+ fs.chmodSync(handoffDir, 0o700);
269
+ childEnv.CARMAR_HANDOFF_DIR = handoffDir;
270
+ process.on("exit", () => { try { fs.rmSync(handoffDir, { recursive: true, force: true }); } catch { /* best effort at exit */ } });
271
+ const planes = PLANE_MODULES.map((mod) => (mod.createPlane ? mod.createPlane({ root, spike, env, audit, settings }) : mod.default));
272
+ let servedCommands = [];
273
+
274
+ // Discovery runs BEFORE the first ready, not beside it: `engines` on that
275
+ // frame is what decides whether the page greys a {python} chunk, and a list
276
+ // that filled in half a second later would grey it and then quietly change
277
+ // 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);
281
+ audit("engine-discovery", { engine: "python", available: pythonFound.available, detail: pythonFound.detail });
282
+ 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)
286
+ ? { available: false, detail: `This Beatrina does not carry the JavaScript worker (${jsWorker}).`, path: "", version: "" }
287
+ : detectNode({ env: process.env, packaged });
288
+ audit("engine-discovery", { engine: "js", available: nodeFound.available, detail: nodeFound.detail });
289
+
290
+ const ENGINE_SPECS = [
291
+ // WP5a — which R, and what the child inherits, come from the resolved
292
+ // settings (`rscript_path`, the child env). Read per spawn, never
293
+ // captured, so Restart R picks up a changed binary.
294
+ { name: "r", label: "R", boot: "eager", available: true,
295
+ factory: () => new REngine({
296
+ workerPath, handoffScript: path.join(root, "engines", "r", "handoff.R"),
297
+ rscript: detectRscript(settings.rscriptPreference()),
298
+ env: { ...childEnv, ...settings.childEnv() },
299
+ }) },
300
+ { name: "python", label: "Python", boot: "on-demand",
301
+ available: pythonFound.available, detail: pythonFound.detail,
302
+ version: pythonFound.version || "",
303
+ runtime: { path: pythonFound.path || "", why: pythonFound.why || "", searched: pythonFound.searched },
304
+ factory: () => new PythonEngine({ workerPath: pythonWorker, python: pythonFound.path, env: { ...childEnv, ...settings.childEnv() } }) },
305
+ { name: "js", label: "JavaScript", boot: "on-demand",
306
+ available: nodeFound.available, detail: nodeFound.detail, version: nodeFound.version || "",
307
+ runtime: { path: nodeFound.path || "", why: nodeFound.detail },
308
+ factory: () => new JsEngine({ workerPath: jsWorker, node: nodeFound.path, selfHosted: Boolean(nodeFound.selfHosted), env: { ...childEnv, ...settings.childEnv() } }) },
309
+ ];
310
+
311
+ // `/health r` before the worker is ready: ask the chosen Rscript once, in
312
+ // the background, so a launcher polling /health the instant the port answers
313
+ // sees the R it is about to get rather than "".
314
+ let rVersionHint = "";
315
+ {
316
+ const { execFile } = await import("node:child_process");
317
+ const rscript = detectRscript(settings.rscriptPreference());
318
+ if (rscript) execFile(rscript, ["--version"], { timeout: 5000 }, (err, stdout, stderr) => {
319
+ const m = String(stdout || "").concat(String(stderr || "")).match(/version (\d+\.\d+(?:\.\d+)?)/);
320
+ if (!err && m) rVersionHint = m[1];
321
+ });
322
+ }
323
+
324
+ const plane = new EnginePool({
325
+ specs: ENGINE_SPECS, audit, deadlineScale, respawnMax,
326
+ decorateReady: (frame, spec) => ({
327
+ ...frame,
328
+ protocol,
329
+ kernel_build: kernelBuild,
330
+ installed_build: installedBuild(),
331
+ ...(newerOnNpm() ? { update_available: newerOnNpm() } : {}),
332
+ // Per engine, never merged: `commands` is a contract about what THIS
333
+ // session answers, and lib/kernel-capability.js refuses an op by name on
334
+ // the strength of it. The host's own verbs are added to the primary's.
335
+ commands: [...new Set([
336
+ ...(Array.isArray(frame.commands) ? frame.commands : [frame.commands]).filter(Boolean),
337
+ ...(spec.name === plane.primaryName ? ["session_restart", ...servedCommands] : []),
338
+ ])],
339
+ features: Array.isArray(frame.features) ? frame.features : (frame.features ? [frame.features] : []),
340
+ engine: spec.name,
341
+ language: frame.language || spec.name,
342
+ language_version: frame.language_version || rVersionOf(frame) || null,
343
+ // EVERY engine this host has a runtime for, on every engine's frame.
344
+ engines: plane.engineNames,
345
+ engine_status: plane.engineRows(),
346
+ host: "beatrix",
347
+ }),
348
+ });
349
+ await plane.start();
350
+
351
+ // ── the server ─────────────────────────────────────────────────────────────
352
+ 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;
355
+ let runtimeFile = "";
356
+ // `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.
358
+ const runtimeRecord = { url: "", file: "", port, pid: process.pid, started: new Date(bootAt).toISOString(), host: "beatrix", app: appId() };
359
+ // The document the session was started with, for `beatrina status`.
360
+ if (env("CARMAR_OPEN_FILE")) runtimeRecord.document = env("CARMAR_OPEN_FILE");
361
+ // A successor started by a handoff keeps the session's name.
362
+ const inheritedTitle = env("CARMAR_SESSION_TITLE").trim();
363
+ if (inheritedTitle) runtimeRecord.title = inheritedTitle.slice(0, 120);
364
+ const handoffFrom = env("CARMAR_HANDOFF_FROM").trim();
365
+ const writeRuntime = () => {
366
+ if (!runtimeFile) return false;
367
+ try { fs.writeFileSync(runtimeFile, JSON.stringify(runtimeRecord), { mode: 0o600 }); return true; } catch { return false; }
368
+ };
369
+ let stopping = false;
370
+ async function shutdown(reason) {
371
+ if (stopping) return;
372
+ stopping = true;
373
+ audit("stopping", { reason });
374
+ try { host.close(); } catch { /* closing */ }
375
+ // A plane may own children of its own (a job's process tree, a pty shell).
376
+ // They are spawned DETACHED so a group signal can reach what they started,
377
+ // which is exactly why nothing reaps them for us. serve.R kills the same
378
+ // trees in its exit handler; this is that handler.
379
+ for (const pl of planes) { try { await pl.shutdown?.(); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
380
+ await plane.stop();
381
+ if (runtimeFile) {
382
+ try {
383
+ const rec = JSON.parse(fs.readFileSync(runtimeFile, "utf8"));
384
+ if (rec.pid === process.pid) fs.unlinkSync(runtimeFile);
385
+ } catch { /* not ours, or already gone */ }
386
+ }
387
+ process.exit(0);
388
+ }
389
+ const host = createHostServer({
390
+ deployment, plane, audit, protocol, kernelBuild, installedBuild, updateAvailable: newerOnNpm, bootAt, app: appId(), fileLaunchCap, here: spike, planes,
391
+ rVersionHint: () => rVersionHint,
392
+ pagePath: () => notebookPage(kernelBuild),
393
+ // The handoff asks for the PIN alone (spike/notebook-page.R): a successor
394
+ // with nothing but the development index to serve is worse than a stale
395
+ // session, so no fallback may answer "the installed build has a page".
396
+ notebookPage: (build) => notebookPage(build, { fallback: false }), notebookFileUrl: (p, prt, cap) => notebookFileUrl(path.resolve(p), prt, cap),
397
+ runtimeRecord: () => runtimeRecord, handoffFrom,
398
+ pendingOpen: env("CARMAR_OPEN_FILE") || null,
399
+ onShutdown: (reason) => shutdown(reason || "explicit Quit request"),
400
+ onPageTitle: (title) => {
401
+ // eslint-disable-next-line no-control-regex
402
+ // `null` is a page-title that named no settable title: the listener mark
403
+ // still goes (a listener with a page is an attached session), the title stays.
404
+ if (title !== null) {
405
+ const clean = String(title).replace(/[\x00-\x1f\x7f]/g, " ").replace(/["\\]/g, "'").trim().slice(0, 120);
406
+ if (clean) runtimeRecord.title = clean; else delete runtimeRecord.title;
407
+ }
408
+ delete runtimeRecord.listen;
409
+ writeRuntime();
410
+ },
411
+ });
412
+ servedCommands = host.commands;
413
+ // A plane that refuses to start (a policy permitting nothing) stops the whole
414
+ // host — and takes the worker it already spawned with it, because the exit
415
+ // handler at the foot of this file has not been registered yet.
416
+ for (const pl of planes) {
417
+ try { await pl.init?.(host.ctx); } catch (e) {
418
+ console.error(`Beatrina: plane ${pl.name} failed to start: ${e.message}`);
419
+ await plane.stop().catch(() => {});
420
+ process.exit(2);
421
+ }
422
+ }
423
+ await new Promise((resolve, reject) => {
424
+ host.server.once("error", reject);
425
+ host.server.listen(port, deployment.bind, resolve);
426
+ });
427
+ const boundPort = host.server.address().port;
428
+ const originOk = `http://${deployment.loopback ? deployment.bind : "127.0.0.1"}:${boundPort}`;
429
+ const url = `${originOk}/`;
430
+ const page = notebookPage(kernelBuild);
431
+ const fileUrl = page ? notebookFileUrl(path.resolve(page), boundPort, fileLaunchCap) : "";
432
+ runtimeRecord.url = url;
433
+ 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 = ""; }
438
+ if (runtimeFile && !writeRuntime()) runtimeFile = "";
439
+ }
440
+ 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") });
442
+ process.stdout.write(`${JSON.stringify({ url, file: fileUrl })}\n`);
443
+ // --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 || "");
449
+ try {
450
+ const child = path.isAbsolute(opener) ? spawn(opener, [target], { stdio: "ignore", detached: true })
451
+ : process.platform === "darwin" ? spawn("open", [target], { stdio: "ignore", detached: true })
452
+ : process.platform === "win32" ? spawn("cmd", ["/c", "start", "", target], { stdio: "ignore", detached: true })
453
+ : spawn("xdg-open", [target], { stdio: "ignore", detached: true });
454
+ child.on("error", () => { /* no desktop opener: the URL is printed above */ });
455
+ child.unref();
456
+ } catch { /* no desktop opener */ }
457
+ }
458
+
459
+ // ── lifetime ───────────────────────────────────────────────────────────────
460
+ // Idle shutdown is OPT-IN. A kernel nobody has EVER connected to gets the boot
461
+ // grace instead, so an unopened launch does not leave an R behind — unless the
462
+ // setting is explicitly 0, which says "headless, keep".
463
+ //
464
+ // WP5a — the clock reads the RESOLVED setting rather than CARMAR_LINGER, so the
465
+ // user's settings.json can turn it on and Settings can change it live. "Still
466
+ // at its default" is the exact rule serve.R's resolve_linger() uses for "never
467
+ // reap": an explicit 0 from the env or the file is a CHOICE and is honoured as
468
+ // one, which is why it is `source === "default"` and not `value === 0`. Read
469
+ // 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;
471
+ let idleSince = null;
472
+ setInterval(() => {
473
+ const lingerS = settings.lingerSeconds();
474
+ const everSeen = host.state.everConnected || host.state.heldAt != null;
475
+ const unopenedDefault = !everSeen && settings.lingerIsDefault();
476
+ if (!(lingerS > 0) && !unopenedDefault) { idleSince = null; return; }
477
+ plane.sweepParked?.(lingerS);
478
+ if (!everSeen && Date.now() - bootAt < bootGraceS * 1000) return;
479
+ const held = host.state.heldAt != null && Date.now() - host.state.heldAt < 30000;
480
+ // A plane that says it is busy holds the clock open. The case this exists
481
+ // for: a detached job three minutes into a check nobody is watching — the
482
+ // one moment a long task matters is the one an idle timer would end it.
483
+ const planeBusy = planes.some((pl) => pl.busy?.() === true);
484
+ const idle = host.sockets.length === 0 && plane.running.size === 0 && !held && !planeBusy;
485
+ if (!idle) { idleSince = null; return; }
486
+ if (idleSince == null) { idleSince = Date.now(); return; }
487
+ const grace = unopenedDefault ? bootGraceS : lingerS;
488
+ if (Date.now() - idleSince >= grace * 1000) {
489
+ const reason = unopenedDefault ? "unopened-launch" : "configured-idle-timeout";
490
+ audit("linger-shutdown", { grace, reason });
491
+ console.error(`Beatrina stopping: ${reason} (grace ${grace} seconds).`);
492
+ shutdown(reason);
493
+ }
494
+ }, 1000).unref();
495
+
496
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => shutdown(sig));
497
+ process.on("exit", () => { try { if (typeof plane.killAll === "function") plane.killAll(); else if (plane.engine && plane.engine.alive) plane.engine.kill(); } catch { /* gone */ } });
498
+ }
499
+
500
+ main().catch((e) => {
501
+ console.error(`Beatrina failed to start: ${e && e.stack ? e.stack : e}`);
502
+ process.exit(2);
503
+ });
@@ -0,0 +1,41 @@
1
+ # host/planes — one supervisor-answered family per module
2
+
3
+ A plane is what `spike/serve.R` calls an allow-list family: the file ops, the
4
+ journal, the AI store, jobs, the terminal, citations, plugins, sessions, the
5
+ MCP plane. Each is one module here, registered by one line in `host/main.mjs`
6
+ (`PLANE_MODULES`), and consulted by `host/server.mjs` before its not-yet
7
+ refusals.
8
+
9
+ ```js
10
+ export function createPlane({ root, spike, env, audit }) {
11
+ return {
12
+ name: "files",
13
+ ops: ["files", "readfile", …], // the frames this plane answers
14
+ agentRefused: ["writefile", …], // subset an mcp-role socket may not send
15
+ agentRefusalFor: (op) => "…", // that op's OWN sentence (serve.R gives each one)
16
+ commands: ["files", "readfile", …], // advertised in ready.commands — only what is served
17
+ capabilities: ["document-journal-v1"], // added to /health capabilities
18
+ async init(ctx) {}, // once, before listen
19
+ handle(cmd, rec, ctx, rawMessage) {}, // one frame; reply with rec.ws.send(ctx.enc({...}))
20
+ http(req, res, pathname, ctx) {}, // return true when handled
21
+ onOpen(rec, ctx) {}, onClose(rec, ctx) {},
22
+ busy() {}, // true holds the idle clock open
23
+ async shutdown() {}, // the kernel is going: reap your children
24
+ };
25
+ }
26
+ ```
27
+
28
+ `busy()` and `shutdown()` are read by `host/main.mjs`. A plane that spawns a
29
+ child spawns it DETACHED (so a group signal can reach what the child started),
30
+ which is exactly why nothing reaps it for us: `shutdown()` is where that is
31
+ done, and it is the counterpart of serve.R's exit handler.
32
+
33
+ `ctx` carries `deployment`, `plane` (the worker plane), `audit`, `sockets`,
34
+ `approvals`, `state`, `enc`, `pageRecs`, `socketClass`, `port()`, `respond`,
35
+ `reject`, `controlRejection`, `queryParam`, `broadcast`, `installedBuild`,
36
+ `kernelBuild`, `fileLaunchCap`, `sessionStamp`.
37
+
38
+ Rules: a page-only op checks `rec.class` against `PAGE_ONLY_CLASSES`
39
+ (served/file/local) — role is an honour system, class is what the upgrade gate
40
+ proved. Records that must not be re-encoded travel as strings. No dependency
41
+ without a stated reason (`node-pty` for the terminal is the first).