beatrina 0.8.6 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +156 -26
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +271 -35
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +364 -239
  35. package/failsafe/session-documents.R +104 -0
  36. package/host/ai-authority.mjs +55 -0
  37. package/host/ai-policy.mjs +48 -26
  38. package/host/bundle.mjs +194 -0
  39. package/host/deployment.mjs +25 -18
  40. package/host/engine-js.mjs +16 -17
  41. package/host/engine-pool.mjs +56 -8
  42. package/host/engine-python.mjs +80 -31
  43. package/host/engine-r.mjs +38 -32
  44. package/host/engine-stdio.mjs +45 -12
  45. package/host/env-names.mjs +148 -0
  46. package/host/gateway-token.mjs +51 -0
  47. package/host/journal-store.mjs +10 -9
  48. package/host/main.mjs +216 -76
  49. package/host/payload.mjs +131 -0
  50. package/host/planes/README.md +1 -1
  51. package/host/planes/ai-store.mjs +6 -5
  52. package/host/planes/ai.mjs +49 -29
  53. package/host/planes/analyze.mjs +6 -5
  54. package/host/planes/bundle.mjs +344 -0
  55. package/host/planes/choose.mjs +404 -0
  56. package/host/planes/cite.mjs +18 -17
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +41 -5
  60. package/host/planes/mcp.mjs +80 -91
  61. package/host/planes/pair.mjs +24 -24
  62. package/host/planes/pipe-term.mjs +2 -2
  63. package/host/planes/plugins.mjs +1 -1
  64. package/host/planes/recent-documents.mjs +170 -0
  65. package/host/planes/sessions.mjs +230 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +13 -11
  68. package/host/planes/test-file.mjs +2 -2
  69. package/host/planes/update.mjs +6 -13
  70. package/host/plugin-store.mjs +35 -52
  71. package/host/recent-documents.mjs +124 -0
  72. package/host/runtime-dir.mjs +47 -0
  73. package/host/server.mjs +123 -21
  74. package/host/session-keep.mjs +70 -0
  75. package/host/settings.mjs +85 -37
  76. package/host/update-record.mjs +3 -2
  77. package/host/user-dirs.mjs +60 -18
  78. package/host/which.mjs +39 -0
  79. package/host/windows-runtime.mjs +6 -3
  80. package/host/worker-plane.mjs +80 -7
  81. package/host/ws.mjs +9 -2
  82. package/host/zip.mjs +237 -0
  83. package/kernel/analyze.R +1 -1
  84. package/{check → kernel/check}/acceptance.mjs +1 -1
  85. package/kernel/check/knit-file.mjs +19639 -0
  86. package/{check → kernel/check}/session.mjs +49 -8
  87. package/kernel/deployment.R +20 -20
  88. package/kernel/examples/NOTICE.md +1 -1
  89. package/kernel/fileio.R +6 -6
  90. package/kernel/index.html +2 -2
  91. package/kernel/job-run.R +83 -22
  92. package/kernel/jobs.R +28 -12
  93. package/kernel/kernel-version +1 -1
  94. package/kernel/kernel.R +24 -21
  95. package/kernel/knitr-run.R +50 -9
  96. package/kernel/latex.R +429 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  98. package/kernel/notebook-page.R +7 -7
  99. package/kernel/project.R +10 -10
  100. package/kernel/settings.R +105 -56
  101. package/kernel/sniff.R +4 -4
  102. package/kernel/typst.R +121 -0
  103. package/kernel/worker.R +602 -123
  104. package/kernel/workspace-keep.R +188 -0
  105. package/lib/agent-authoring-contract.js +28 -19
  106. package/lib/cell-kinds.js +3 -3
  107. package/lib/engine-labels.js +4 -4
  108. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  109. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  110. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  111. package/package.json +4 -3
  112. package/carmar_V0.8.6.html +0 -1310
@@ -7,7 +7,7 @@
7
7
  // data the console history, the appearance carry, the document journal,
8
8
  // the AI conversation store
9
9
  //
10
- // serve.R reaches them through `tools::R_user_dir("carmar", which)`, and every
10
+ // serve.R reaches them through `tools::R_user_dir("beatrina", which)`, and every
11
11
  // suite in the battery relocates them for a test by setting `R_USER_CONFIG_DIR`
12
12
  // / `R_USER_DATA_DIR` — the variables R itself honours. So the host cannot
13
13
  // invent its own layout: a JavaScript supervisor writing to `~/.beatrix` would
@@ -20,21 +20,30 @@
20
20
  // leaving one arm out is how the next store to need it quietly grows a second
21
21
  // layout.
22
22
 
23
+ import crypto from "node:crypto";
23
24
  import fs from "node:fs";
24
25
  import os from "node:os";
25
26
  import path from "node:path";
27
+ import { envReader } from "./env-names.mjs";
26
28
 
27
- const envOf = (name) => (process.env[name] == null ? "" : String(process.env[name]));
29
+ const envOf = envReader(process.env); // dual-read: BEATRINA_STATE or its older spelling (host/env-names.mjs)
28
30
 
29
31
  /**
30
32
  * Which R package's folders this host keeps its data, settings and caches in.
31
33
  *
32
- * "carmar" unless BEATRIX_APP_ID names another: the `beatrina` command sets
33
- * it to "beatrina" (tools/npm/bin/beatrina.mjs), so a Beatrina installed
34
- * beside CarmaR keeps its OWN settings, AI key, console history, document
35
- * history, plugins and caches instead of reading and writing CarmaR's. The
36
- * kernel runtime directory (~/.carmar/run) is deliberately NOT keyed on it:
37
- * it is how the Sessions pane and the MCP bridge find every running kernel.
34
+ * "beatrina" unless BEATRIX_APP_ID names another (the suites do). Every door
35
+ * of the product — the `beatrina` command, the pip package, the R package, a
36
+ * bare `node host/main.mjs` — therefore lands in the same folders.
37
+ * beatrina-names: keep-start — the OTHER product is named here on purpose. It is
38
+ * the whole subject of the sentence, and renaming it left this comment saying
39
+ * that Beatrina does not read Beatrina's own folders.
40
+ * The other product's folders (`R/carmar`, CarmaR's own, frozen at 0.8.6) are
41
+ * never read, moved or written: a Beatrina installed beside CarmaR keeps its OWN
42
+ * settings, AI key, console history, document history, plugins and caches.
43
+ * beatrina-names: keep-end
44
+ * The kernel runtime directory (~/.beatrina/run, host/runtime-dir.mjs) is
45
+ * deliberately NOT keyed on it: it is how the Sessions pane and the MCP bridge
46
+ * find every running kernel.
38
47
  * A value that is not an R package name is refused, loudly — a typo must not
39
48
  * quietly become a third data folder.
40
49
  *
@@ -43,7 +52,7 @@ const envOf = (name) => (process.env[name] == null ? "" : String(process.env[nam
43
52
  */
44
53
  export function appId(env = envOf) {
45
54
  const id = String(env("BEATRIX_APP_ID") || "").trim();
46
- if (!id) return "carmar";
55
+ if (!id) return "beatrina";
47
56
  if (!/^[A-Za-z][A-Za-z0-9.]*[A-Za-z0-9]$/.test(id)) {
48
57
  throw new Error(`BEATRIX_APP_ID must be an R package name (letters, digits and dots); it is ${JSON.stringify(id)}.`);
49
58
  }
@@ -53,28 +62,31 @@ export function appId(env = envOf) {
53
62
  /**
54
63
  * `tools::R_user_dir(package, which)`.
55
64
  *
56
- * @param {string} pkg the package name ("carmar")
65
+ * @param {string} pkg the package name ("beatrina")
57
66
  * @param {"data"|"config"|"cache"} which
67
+ * @param {(name: string) => string} [env] an env reader, for callers that
68
+ * inject one (the plugin store's layer paths are computed per request, and
69
+ * its suites relocate them with R_USER_DATA_DIR). Defaults to this module's.
58
70
  * @returns {string} an absolute directory path (NOT created)
59
71
  */
60
- export function rUserDir(pkg, which = "data") {
72
+ export function rUserDir(pkg, which = "data", env = envOf) {
61
73
  const home = os.homedir();
62
74
  const windows = process.platform === "win32";
63
75
  const darwin = process.platform === "darwin";
64
76
  let base = "";
65
77
  if (which === "data") {
66
- base = envOf("R_USER_DATA_DIR") || envOf("XDG_DATA_HOME")
67
- || (windows ? path.join(envOf("APPDATA"), "R", "data")
78
+ base = env("R_USER_DATA_DIR") || env("XDG_DATA_HOME")
79
+ || (windows ? path.join(env("APPDATA"), "R", "data")
68
80
  : darwin ? path.join(home, "Library", "Application Support", "org.R-project.R")
69
81
  : path.join(home, ".local", "share"));
70
82
  } else if (which === "config") {
71
- base = envOf("R_USER_CONFIG_DIR") || envOf("XDG_CONFIG_HOME")
72
- || (windows ? path.join(envOf("APPDATA"), "R", "config")
83
+ base = env("R_USER_CONFIG_DIR") || env("XDG_CONFIG_HOME")
84
+ || (windows ? path.join(env("APPDATA"), "R", "config")
73
85
  : darwin ? path.join(home, "Library", "Preferences", "org.R-project.R")
74
86
  : path.join(home, ".config"));
75
87
  } else {
76
- base = envOf("R_USER_CACHE_DIR") || envOf("XDG_CACHE_HOME")
77
- || (windows ? path.join(envOf("LOCALAPPDATA"), "R", "cache")
88
+ base = env("R_USER_CACHE_DIR") || env("XDG_CACHE_HOME")
89
+ || (windows ? path.join(env("LOCALAPPDATA"), "R", "cache")
78
90
  : darwin ? path.join(home, "Library", "Caches", "org.R-project.R")
79
91
  : path.join(home, ".cache"));
80
92
  }
@@ -94,7 +106,7 @@ export function ensureUserDir(pkg, which) {
94
106
  * variable is unset.
95
107
  */
96
108
  export function stateDir() {
97
- return envOf("CARMAR_STATE") || rUserDir(appId(), "data");
109
+ return envOf("BEATRINA_STATE") || rUserDir(appId(), "data");
98
110
  }
99
111
 
100
112
  /**
@@ -115,3 +127,33 @@ export function privateModeHeld(file, platform = process.platform) {
115
127
  if (platform === "win32") return true;
116
128
  return (fs.statSync(file).mode & 0o777) === 0o600;
117
129
  }
130
+
131
+ /**
132
+ * Replace `file` with `text`, privately and atomically.
133
+ *
134
+ * A sibling is written 0600, checked (`privateModeHeld`), and renamed over the
135
+ * file — so an interrupted write leaves the PREVIOUS file intact rather than a
136
+ * half-written one, and no reader ever sees the file mid-write. The AI store's
137
+ * rule (host/planes/ai-store.mjs), lifted here for the small per-user records
138
+ * that are rewritten whole: the recent-documents list first. The directory is
139
+ * created on the way; a failure at any step throws with that step's sentence
140
+ * and takes the sibling with it. No fsync: safe against the process dying,
141
+ * not the machine.
142
+ *
143
+ * @param {string} file absolute path of the record
144
+ * @param {string} text its whole new content
145
+ */
146
+ export function writePrivateAtomic(file, text) {
147
+ const dir = path.dirname(file);
148
+ fs.mkdirSync(dir, { recursive: true });
149
+ const temp = path.join(dir, `.${path.basename(file)}.${process.pid}-${crypto.randomBytes(6).toString("hex")}`);
150
+ try {
151
+ fs.writeFileSync(temp, Buffer.from(String(text), "utf8"), { mode: 0o600 });
152
+ fs.chmodSync(temp, 0o600);
153
+ if (!privateModeHeld(temp)) throw new Error(`could not make ${path.basename(file)} private (0600)`);
154
+ fs.renameSync(temp, file);
155
+ } catch (e) {
156
+ try { fs.unlinkSync(temp); } catch { /* never written, or already renamed into place */ }
157
+ throw e;
158
+ }
159
+ }
package/host/which.mjs ADDED
@@ -0,0 +1,39 @@
1
+ // which.mjs — find an executable on PATH, synchronously, with no shell.
2
+ //
3
+ // `Sys.which()` for the host. Lifted out of planes/update.mjs the day a second
4
+ // plane (planes/choose.mjs, the OS file panel) needed the same answer, so the
5
+ // two cannot drift on what "on PATH" means. Nothing here spawns anything: a
6
+ // candidate is a file the current user may execute, found by walking PATH in
7
+ // order, exactly as the shell would resolve the bare name.
8
+ //
9
+ // Windows resolves a bare name through PATHEXT (`powershell` → `powershell.exe`),
10
+ // so a name without an extension is tried with each of those too; a name that
11
+ // already carries one is tried as given first.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ /**
17
+ * @param {string} name a bare command name ("osascript", "powershell.exe")
18
+ * @param {Record<string,string|undefined>} [env] the environment whose PATH is walked
19
+ * @returns {string} the first executable found, or "" when there is none
20
+ */
21
+ export function whichSync(name, env = process.env) {
22
+ if (typeof name !== "string" || !name) return "";
23
+ const dirs = String(env.PATH || env.Path || "").split(path.delimiter).filter(Boolean);
24
+ const exts = process.platform === "win32" && !path.extname(name)
25
+ ? ["", ...String(env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)]
26
+ : [""];
27
+ for (const dir of dirs) {
28
+ for (const ext of exts) {
29
+ const cand = path.join(dir, name + ext);
30
+ try {
31
+ fs.accessSync(cand, fs.constants.X_OK);
32
+ if (fs.statSync(cand).isFile()) return cand;
33
+ } catch { /* not here; the next candidate */ }
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+
39
+ export default whichSync;
@@ -21,7 +21,7 @@
21
21
  // processx ships exactly that program, `interrupt.exe <pid> c`
22
22
  // (processx/src/tools/interrupt.c: FreeConsole → AttachConsole(pid) →
23
23
  // ignore Ctrl+C itself → GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)), and
24
- // processx is already a dependency of the carmar package, so the helper is
24
+ // processx is already a dependency of the beatrina package, so the helper is
25
25
  // on every machine that installed it. For the event to reach the worker
26
26
  // and not the host, the worker needs a console of its own: it is spawned
27
27
  // with `windowsHide` (CREATE_NO_WINDOW), the flag processx uses too.
@@ -38,6 +38,7 @@
38
38
 
39
39
  import fs from "node:fs";
40
40
  import path from "node:path";
41
+ import { readEnv } from "./env-names.mjs";
41
42
 
42
43
  const defaultFs = {
43
44
  exists: (p) => { try { return fs.existsSync(p); } catch { return false; } },
@@ -104,7 +105,8 @@ export function detectRterm(rscript, io = defaultFs) {
104
105
  export const RTERM_INTERACTIVE_ARGS = Object.freeze(["--ess", "--no-echo", "--no-save", "--no-restore", "--no-site-file"]);
105
106
 
106
107
  /**
107
- * Where processx's `interrupt.exe` is. `CARMAR_WIN_INTERRUPT` first (the R
108
+ * Where processx's `interrupt.exe` is. `BEATRINA_WIN_INTERRUPT` (or the old
109
+ * `BEATRINA_WIN_INTERRUPT`) first (the R
108
110
  * launcher sets it from `system.file(package = "processx")`, which knows every
109
111
  * library path), then a helper shipped beside the host, then the libraries a
110
112
  * default install writes to: the R installation's own, R_LIBS_USER, and
@@ -115,7 +117,8 @@ export function findInterruptTool({ env = process.env, rscript = "", hostRoot =
115
117
  const w = path.win32;
116
118
  const tail = ["processx", "bin", "x64", "interrupt.exe"];
117
119
  const cands = [];
118
- if (env.CARMAR_WIN_INTERRUPT) cands.push(env.CARMAR_WIN_INTERRUPT);
120
+ const told = readEnv(env, "WIN_INTERRUPT");
121
+ if (told) cands.push(told);
119
122
  if (hostRoot) cands.push(w.join(hostRoot, "bin", "interrupt.exe"));
120
123
  if (rscript) cands.push(w.join(w.dirname(w.dirname(rscript)), "library", ...tail));
121
124
  if (env.R_HOME) cands.push(w.join(env.R_HOME, "library", ...tail));
@@ -35,6 +35,7 @@
35
35
  // an explicit `restart` is the one door out of it.
36
36
 
37
37
  import { EventEmitter } from "node:events";
38
+ import { RESUME_REPORT_MS, keepTokenOk } from "./session-keep.mjs";
38
39
 
39
40
  export const WORKER_DEADLINE_FAST = 120;
40
41
  export const WORKER_DEADLINE_SLOW = 600;
@@ -45,7 +46,8 @@ export const WORKER_DEADLINES = Object.freeze({
45
46
  package_help: WORKER_DEADLINE_FAST, project_status: WORKER_DEADLINE_FAST, rm: WORKER_DEADLINE_FAST,
46
47
  debug_breaks: WORKER_DEADLINE_FAST, files: WORKER_DEADLINE_FAST, mkdir: WORKER_DEADLINE_FAST,
47
48
  renamepath: WORKER_DEADLINE_FAST, sniff: WORKER_DEADLINE_FAST,
48
- view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW, import: WORKER_DEADLINE_SLOW,
49
+ view: WORKER_DEADLINE_SLOW, colstats: WORKER_DEADLINE_SLOW, column_values: WORKER_DEADLINE_SLOW,
50
+ import: WORKER_DEADLINE_SLOW,
49
51
  format: WORKER_DEADLINE_SLOW, doctor: WORKER_DEADLINE_SLOW, readfile: WORKER_DEADLINE_SLOW,
50
52
  writefile: WORKER_DEADLINE_SLOW, writefiles_atomic: WORKER_DEADLINE_SLOW,
51
53
  copypath: WORKER_DEADLINE_SLOW, deletepath: WORKER_DEADLINE_SLOW, revealpath: WORKER_DEADLINE_SLOW,
@@ -119,6 +121,10 @@ export class WorkerPlane extends EventEmitter {
119
121
  this.terminal = null; // {wireId, route, frame, timer}
120
122
  this.running = new Set(); // exec wire ids in flight (the busy guard's evidence)
121
123
  this.hello = null; // the decorated ready payload, replayed to late pages
124
+ // "Restart, keep variables" (host/session-keep.mjs): a token for the NEXT ready worker, consumed
125
+ // once; and the restore's report, replayed to a page that connects within RESUME_REPORT_MS.
126
+ this.resumePending = null;
127
+ this.resumeReport = null;
122
128
  this.notice = null; // a kept give-up notice
123
129
  this.readyFrame = null;
124
130
  this.workerWd = process.cwd();
@@ -314,8 +320,8 @@ export class WorkerPlane extends EventEmitter {
314
320
  if (this.alive) this.engine.kill();
315
321
  }
316
322
 
317
- async restart() {
318
- this.audit("restart", {});
323
+ async restart({ resume } = {}) {
324
+ this.audit("restart", { keep: keepTokenOk(resume) });
319
325
  this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
320
326
  this.expectedExit = true;
321
327
  if (this.engine) await this.engine.stop(1000);
@@ -325,7 +331,65 @@ export class WorkerPlane extends EventEmitter {
325
331
  this.restartAttempts = 0;
326
332
  this.gaveUp = false;
327
333
  this.notice = null;
328
- await this.start();
334
+ this.resumePending = keepTokenOk(resume) ? resume : null;
335
+ try { await this.start(); } catch (e) { this.refuseStart(e); throw e; }
336
+ }
337
+
338
+ /**
339
+ * A start that could not happen, said to every page instead of left as a spinner. Restart R used
340
+ * to reject into server.mjs's audit line and nothing else, so the page waited for a ready that
341
+ * would never come. A payload that drifted under this session (host/payload.mjs) lands here too,
342
+ * and its sentence already names the door out ("Restart into").
343
+ */
344
+ refuseStart(e) {
345
+ const drift = e && e.code === "BEATRINA_PAYLOAD_DRIFT";
346
+ this.recovering = false;
347
+ this.gaveUp = true;
348
+ const message = drift ? e.message
349
+ : `${this.label} would not start: ${e && e.message ? e.message : e}. Beatrina is still running — the notebook, `
350
+ + "your text and Save all work. Restart to try again.";
351
+ this.audit(drift ? "worker-start-refused" : "worker-start-failed", { detail: e && e.message ? e.message : String(e) });
352
+ this.notice = enc({ type: "worker-died", engine: this.name, recovering: false, terminal: true,
353
+ attempts: this.restartAttempts, message, reason: drift ? "payload-drift" : "start-failed" });
354
+ this.emit("broadcast", this.notice);
355
+ }
356
+
357
+ /** A token for the next ready worker (a handoff successor's BEATRINA_RESUME_TOKEN). */
358
+ setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
359
+
360
+ /**
361
+ * Ask the fresh worker to restore a kept workspace, and tell every page what came back. Queued like
362
+ * any command, so a chunk run pressed meanwhile waits for the restore rather than racing it.
363
+ */
364
+ startResume() {
365
+ const token = this.resumePending;
366
+ this.resumePending = null;
367
+ if (!keepTokenOk(token)) return;
368
+ this.audit("resume-start", {});
369
+ this.internal({ type: "resume", token }, (frame) => {
370
+ if (scalarChr(frame.cwd)) this.workerWd = frame.cwd;
371
+ const report = { type: "workspace_restored", restored: frame.restored ?? 0, failed: frame.failed ?? [],
372
+ skipped: frame.skipped ?? [], wd_missing: frame.wd_missing ?? null, error: frame.error ?? null };
373
+ const payload = enc(report);
374
+ this.resumeReport = { at: Date.now(), payload };
375
+ this.audit("resume-done", { restored: report.restored, failed: Array.isArray(report.failed) ? report.failed.length : 0, error: report.error != null });
376
+ this.emit("broadcast-pages", payload);
377
+ });
378
+ }
379
+
380
+ /** The report of a restore that finished in the last RESUME_REPORT_MS, or null. */
381
+ freshResumeReport(now = Date.now()) {
382
+ return this.resumeReport && now - this.resumeReport.at < RESUME_REPORT_MS ? this.resumeReport.payload : null;
383
+ }
384
+
385
+ /** Cancel a `suspend` — only one THIS page asked for, never someone else's run. */
386
+ cancelSuspend(rec) {
387
+ const route = this.active != null ? this.routes.get(this.active) : null;
388
+ if (this.activeType !== "suspend" || !route || route.rec !== rec) return false;
389
+ this.audit("suspend-cancel", {});
390
+ const wireId = this.active;
391
+ this.engine.interrupt(() => wireId === this.active);
392
+ return true;
329
393
  }
330
394
 
331
395
  resetSession() {
@@ -456,12 +520,12 @@ export class WorkerPlane extends EventEmitter {
456
520
  try { this.engine.debugCommand(action, expr); } catch { /* the exit event reports it */ }
457
521
  return undefined;
458
522
  }
459
- const line = { continue: "c", over: "n", into: "s", out: "f", where: ".carmar_debug_where()",
460
- abort: 'invokeRestart("carmar_abort_cell")', eval: scalarChr(cmd.expr) ? cmd.expr : null }[action] ?? null;
523
+ const line = { continue: "c", over: "n", into: "s", out: "f", where: ".beatrina_debug_where()",
524
+ abort: 'invokeRestart("beatrina_abort_cell")', eval: scalarChr(cmd.expr) ? cmd.expr : null }[action] ?? null;
461
525
  if (line == null || /[\n\r]/.test(line) || line.length > 4000) return err("bad debug command");
462
526
  this.audit("debug_cmd", { action });
463
527
  try { this.engine.console(line); } catch { /* the exit event reports it */ }
464
- if (["over", "into", "out"].includes(action)) { try { this.engine.console(".carmar_debug_where()"); } catch { /* same */ } }
528
+ if (["over", "into", "out"].includes(action)) { try { this.engine.console(".beatrina_debug_where()"); } catch { /* same */ } }
465
529
  return undefined;
466
530
  }
467
531
 
@@ -535,6 +599,7 @@ export class WorkerPlane extends EventEmitter {
535
599
  this.hello = payload;
536
600
  this.emit("broadcast", payload);
537
601
  this.emit("ready", decorated);
602
+ if (this.resumePending) this.startResume();
538
603
  return;
539
604
  }
540
605
  this.emit("broadcast", relayFrame(e));
@@ -606,6 +671,14 @@ export class WorkerPlane extends EventEmitter {
606
671
  let started = null;
607
672
  try { started = this.start(); } catch (e) { this.audit("worker-respawn-failed", { attempt, detail: e.message }); }
608
673
  if (!started) return;
674
+ // start() is async: a factory or spawnPlan throwing rejects its promise.
675
+ // Keep recovery's bounded retry loop alive instead of terminating Node
676
+ // with an unhandled rejection when an installation moves mid-session.
677
+ started.catch((e) => {
678
+ this.audit("worker-respawn-failed", { attempt, detail: e.message });
679
+ // Retrying cannot un-replace the files: say so now rather than after six attempts.
680
+ if (e && e.code === "BEATRINA_PAYLOAD_DRIFT") this.refuseStart(e);
681
+ });
609
682
  this.audit("worker-respawn", { attempt });
610
683
  this.emit("broadcast", enc({ type: "worker-restarting", engine: this.name, attempt }));
611
684
  }
package/host/ws.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // ws.mjs — a WebSocket SERVER, RFC 6455, over Node's http upgrade event.
2
2
  //
3
3
  // Node ships a WebSocket client (>= 22) and no server, and the host has no
4
- // dependencies by decision (tools/mcp/carmar-mcp.mjs and test/fixtures/
4
+ // dependencies by decision (tools/mcp/beatrina-mcp.mjs and test/fixtures/
5
5
  // ws-origin.mjs set the precedent: a few hundred lines of protocol beat a
6
6
  // package the security review must then read). What this implements is the
7
7
  // subset a notebook page uses: text frames, masked client → unmasked server,
@@ -29,6 +29,8 @@ const MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
29
29
  * @property {(code?: number, reason?: string) => void} close
30
30
  * @property {(fn: (text: string) => void) => void} onMessage
31
31
  * @property {(fn: () => void) => void} onClose
32
+ * @property {() => boolean} ping a protocol ping (opcode 9); the peer's network stack answers it
33
+ * @property {(fn: () => void) => void} onPong called for each pong the peer sends back
32
34
  * @property {import("node:http").IncomingMessage} request the upgrade request, kept —
33
35
  * the gate's reasoning is recomputed from it (socket_class in serve.R)
34
36
  * @property {boolean} open
@@ -74,6 +76,7 @@ function wrap(raw, request, head) {
74
76
  let open = true;
75
77
  let closeSent = false;
76
78
  const messageHandlers = [];
79
+ const pongHandlers = [];
77
80
  const closeHandlers = [];
78
81
 
79
82
  const finish = () => {
@@ -138,7 +141,9 @@ function wrap(raw, request, head) {
138
141
  return;
139
142
  }
140
143
  if (opcode === 0x9) { sendFrame(0xa, payload); continue; } // ping → pong
141
- if (opcode === 0xa) continue; // pong: a reply to nothing we sent
144
+ // A pong answers the reaper's ping (server.mjs): the peer's network stack sent it, so the
145
+ // transport is alive even when the page's own thread is busy or throttled and sends nothing.
146
+ if (opcode === 0xa) { for (const fn of pongHandlers) fn(); continue; }
142
147
  if (opcode === 0x1 || opcode === 0x2) {
143
148
  if (partial) return closeWith(1002, "interleaved message");
144
149
  partial = { opcode, chunks: [payload], bytes: payload.length };
@@ -169,6 +174,8 @@ function wrap(raw, request, head) {
169
174
  close: (code = 1000, reason = "") => closeWith(code, reason),
170
175
  onMessage: (fn) => { messageHandlers.push(fn); },
171
176
  onClose: (fn) => { closeHandlers.push(fn); },
177
+ ping: () => sendFrame(0x9, Buffer.alloc(0)),
178
+ onPong: (fn) => { pongHandlers.push(fn); },
172
179
  };
173
180
  }
174
181
 
package/host/zip.mjs ADDED
@@ -0,0 +1,237 @@
1
+ // zip.mjs — write and read standard ZIP archives, with Node's zlib and nothing else.
2
+ //
3
+ // The container under `.btrn` (docs/results-plan.md R2). A .btrn must open in
4
+ // every unzip there is — macOS Archive Utility, `unzip`, R's utils::unzip() —
5
+ // so this writes the plain, universal subset of the format and nothing clever:
6
+ // one local header + data per entry, a central directory, the end record.
7
+ // Method 0 (stored) or 8 (deflated); UTF-8 names (flag bit 11); no ZIP64 (a
8
+ // .btrn holds ≤ 100 MB of data by design — past that the data lives beside
9
+ // it), no encryption, no data descriptors. zlib.crc32 is Node ≥ 22.2.
10
+ //
11
+ // READING IS READING SOMETHING FROM ELSEWHERE, so the reader refuses before it
12
+ // trusts:
13
+ // · a name that is absolute, has a `..` segment, a drive letter, a
14
+ // backslash, a NUL, or is empty — the zip-slip family, refused by name;
15
+ // · the same name twice;
16
+ // · an encrypted entry, or a method other than 0/8;
17
+ // · more than `maxTotal` bytes uncompressed in all — the zip-bomb family,
18
+ // refused with its numbers BEFORE inflating (a per-entry ratio is
19
+ // optional: deflate tops out near 1032×, so it guards nothing by default);
20
+ // · an entry that inflates to more than it declared (zlib's
21
+ // maxOutputLength enforces the declared size), or whose CRC-32 differs.
22
+ // Every refusal is a thrown Error with `code` set, so callers can say which.
23
+
24
+ import zlib from "node:zlib";
25
+
26
+ const SIG_LOCAL = 0x04034b50;
27
+ const SIG_CENTRAL = 0x02014b50;
28
+ const SIG_END = 0x06054b50;
29
+ const FLAG_UTF8 = 0x0800;
30
+ const U32 = 0xffffffff;
31
+
32
+ /** Date → the two 16-bit MS-DOS fields (2-second resolution, 1980..2107). */
33
+ function dosTime(date) {
34
+ const d = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date(1980, 0, 1);
35
+ const year = Math.min(2107, Math.max(1980, d.getFullYear()));
36
+ return {
37
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
38
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
39
+ };
40
+ }
41
+
42
+ const fail = (code, message) => Object.assign(new Error(message), { code });
43
+
44
+ /** The Info-ZIP Unicode Path extra field: version 1, CRC-32 of the header name, the UTF-8 name. */
45
+ function unicodePathExtra(nameBytes) {
46
+ const field = Buffer.alloc(4 + 5 + nameBytes.length);
47
+ field.writeUInt16LE(0x7075, 0);
48
+ field.writeUInt16LE(5 + nameBytes.length, 2);
49
+ field.writeUInt8(1, 4);
50
+ field.writeUInt32LE(zlib.crc32(nameBytes) >>> 0, 5);
51
+ nameBytes.copy(field, 9);
52
+ return field;
53
+ }
54
+
55
+ /**
56
+ * Is this an entry name a reader may use? Relative, forward slashes, no
57
+ * `..`/`.` segment, no drive, no NUL, not empty. A directory entry ends in `/`.
58
+ */
59
+ export function safeEntryName(name) {
60
+ const n = String(name ?? "");
61
+ if (!n || n.length > 1024 || /[\u0000\\]/.test(n)) return false;
62
+ if (n.startsWith("/") || /^[A-Za-z]:/.test(n)) return false;
63
+ const parts = n.replace(/\/$/, "").split("/");
64
+ return parts.every((p) => p !== "" && p !== "." && p !== "..");
65
+ }
66
+
67
+ /**
68
+ * Build a ZIP in memory.
69
+ *
70
+ * @param {Array<{name:string, data:Buffer|Uint8Array|string, compress?:boolean, date?:Date}>} entries
71
+ * `compress` false stores the bytes (right for PNG/JPEG, already compressed);
72
+ * the default deflates, and keeps the stored form when deflate does not help.
73
+ * @returns {Buffer}
74
+ */
75
+ export function writeZip(entries, { date = new Date() } = {}) {
76
+ if (!Array.isArray(entries)) throw fail("zip_input", "writeZip needs a list of entries");
77
+ if (entries.length > 0xfffe) throw fail("zip_too_many", `${entries.length} entries is more than a plain ZIP holds`);
78
+ const seen = new Set();
79
+ const locals = [];
80
+ const centrals = [];
81
+ let offset = 0;
82
+ for (const entry of entries) {
83
+ const name = String(entry.name ?? "");
84
+ if (!safeEntryName(name)) throw fail("zip_bad_name", `unsafe entry name: ${JSON.stringify(name)}`);
85
+ if (seen.has(name)) throw fail("zip_duplicate", `the entry ${name} appears twice`);
86
+ seen.add(name);
87
+ const raw = Buffer.isBuffer(entry.data) ? entry.data
88
+ : typeof entry.data === "string" ? Buffer.from(entry.data, "utf8") : Buffer.from(entry.data ?? []);
89
+ if (raw.length >= U32) throw fail("zip_too_large", `${name} is over 4 GB; a plain ZIP cannot hold it`);
90
+ const crc = zlib.crc32(raw) >>> 0;
91
+ let method = 0;
92
+ let body = raw;
93
+ if (entry.compress !== false && raw.length) {
94
+ const deflated = zlib.deflateRawSync(raw, { level: 6 });
95
+ if (deflated.length < raw.length) { method = 8; body = deflated; }
96
+ }
97
+ const nameBytes = Buffer.from(name, "utf8");
98
+ // A non-ASCII name also travels in the Info-ZIP Unicode Path field
99
+ // (0x7075): macOS's bundled unzip ignores the UTF-8 flag and mangles
100
+ // "ünïcode.csv" without it; readers that honour the flag ignore this.
101
+ const extra = /[^\x00-\x7f]/.test(name) ? unicodePathExtra(nameBytes) : Buffer.alloc(0);
102
+ const { time, date: dd } = dosTime(entry.date || date);
103
+ const local = Buffer.alloc(30);
104
+ local.writeUInt32LE(SIG_LOCAL, 0);
105
+ local.writeUInt16LE(20, 4);
106
+ local.writeUInt16LE(FLAG_UTF8, 6);
107
+ local.writeUInt16LE(method, 8);
108
+ local.writeUInt16LE(time, 10);
109
+ local.writeUInt16LE(dd, 12);
110
+ local.writeUInt32LE(crc, 14);
111
+ local.writeUInt32LE(body.length, 18);
112
+ local.writeUInt32LE(raw.length, 22);
113
+ local.writeUInt16LE(nameBytes.length, 26);
114
+ local.writeUInt16LE(extra.length, 28);
115
+ const central = Buffer.alloc(46);
116
+ central.writeUInt32LE(SIG_CENTRAL, 0);
117
+ // Made by UNIX (3), spec 2.0: a reader then takes the name's bytes as they
118
+ // are. Marked MS-DOS (0), macOS's bundled unzip (6.00, no Unicode support)
119
+ // translated every non-ASCII name from code page 437 and wrote garbage.
120
+ central.writeUInt16LE((3 << 8) | 20, 4);
121
+ central.writeUInt16LE(20, 6);
122
+ central.writeUInt16LE(FLAG_UTF8, 8);
123
+ central.writeUInt16LE(method, 10);
124
+ central.writeUInt16LE(time, 12);
125
+ central.writeUInt16LE(dd, 14);
126
+ central.writeUInt32LE(crc, 16);
127
+ central.writeUInt32LE(body.length, 20);
128
+ central.writeUInt32LE(raw.length, 24);
129
+ central.writeUInt16LE(nameBytes.length, 28);
130
+ central.writeUInt16LE(extra.length, 30);
131
+ central.writeUInt32LE(((name.endsWith("/") ? 0o40755 : 0o100644) << 16) >>> 0, 38); // rw-r--r-- (a dir rwxr-xr-x)
132
+ central.writeUInt32LE(offset, 42); // comment, disk, internal attrs: 0
133
+ locals.push(local, nameBytes, extra, body);
134
+ centrals.push(central, nameBytes, extra);
135
+ offset += local.length + nameBytes.length + extra.length + body.length;
136
+ if (offset >= U32) throw fail("zip_too_large", "the archive would pass 4 GB; a plain ZIP cannot hold it");
137
+ }
138
+ const cd = Buffer.concat(centrals);
139
+ const end = Buffer.alloc(22);
140
+ end.writeUInt32LE(SIG_END, 0);
141
+ end.writeUInt16LE(entries.length, 8);
142
+ end.writeUInt16LE(entries.length, 10);
143
+ end.writeUInt32LE(cd.length, 12);
144
+ end.writeUInt32LE(offset, 16);
145
+ return Buffer.concat([...locals, cd, end]);
146
+ }
147
+
148
+ /**
149
+ * The central directory: every entry's name, sizes, method and where its
150
+ * bytes are — nothing inflated. What `bundle_list` shows, and what readZip
151
+ * checks the caps against before touching any data.
152
+ *
153
+ * @param {Buffer} buf
154
+ * @returns {Array<{name:string, method:number, crc:number, csize:number, usize:number, offset:number, dir:boolean}>}
155
+ */
156
+ export function listZip(buf) {
157
+ if (!Buffer.isBuffer(buf) || buf.length < 22) throw fail("zip_not_zip", "not a ZIP archive (too short)");
158
+ let end = -1;
159
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i -= 1) {
160
+ if (buf.readUInt32LE(i) === SIG_END) { end = i; break; }
161
+ }
162
+ if (end < 0) throw fail("zip_not_zip", "not a ZIP archive (no end record)");
163
+ const count = buf.readUInt16LE(end + 10);
164
+ const cdSize = buf.readUInt32LE(end + 12);
165
+ const cdOffset = buf.readUInt32LE(end + 16);
166
+ if (count === 0xffff || cdOffset === U32) throw fail("zip_zip64", "a ZIP64 archive — not a .btrn this version wrote");
167
+ if (cdOffset + cdSize > end) throw fail("zip_corrupt", "the central directory runs past the end record");
168
+ const out = [];
169
+ const names = new Set();
170
+ let p = cdOffset;
171
+ for (let k = 0; k < count; k += 1) {
172
+ if (p + 46 > end || buf.readUInt32LE(p) !== SIG_CENTRAL) throw fail("zip_corrupt", `central directory entry ${k + 1} is damaged`);
173
+ const flags = buf.readUInt16LE(p + 8);
174
+ const method = buf.readUInt16LE(p + 10);
175
+ const crc = buf.readUInt32LE(p + 16) >>> 0;
176
+ const csize = buf.readUInt32LE(p + 20);
177
+ const usize = buf.readUInt32LE(p + 24);
178
+ const nlen = buf.readUInt16LE(p + 28);
179
+ const xlen = buf.readUInt16LE(p + 30);
180
+ const clen = buf.readUInt16LE(p + 32);
181
+ const offset = buf.readUInt32LE(p + 42);
182
+ const name = buf.subarray(p + 46, p + 46 + nlen).toString((flags & FLAG_UTF8) ? "utf8" : "latin1");
183
+ if (!safeEntryName(name)) throw fail("zip_bad_name", `unsafe entry name in the archive: ${JSON.stringify(name)}`);
184
+ if (names.has(name)) throw fail("zip_duplicate", `the entry ${name} appears twice`);
185
+ names.add(name);
186
+ if (flags & 0x0001) throw fail("zip_encrypted", `${name} is encrypted`);
187
+ if (method !== 0 && method !== 8) throw fail("zip_method", `${name} uses compression method ${method}; only stored and deflated are read`);
188
+ out.push({ name, method, crc, csize, usize, offset, dir: name.endsWith("/") });
189
+ p += 46 + nlen + xlen + clen;
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Read a ZIP: every file entry's bytes, verified.
196
+ *
197
+ * @param {Buffer} buf
198
+ * @param {{maxTotal?:number, maxRatio?:number, only?:(name:string)=>boolean}} [opts]
199
+ * the cap on the whole archive's uncompressed bytes (default 2 GB); an
200
+ * optional per-entry expansion cap (none by default: one layer of deflate
201
+ * cannot pass ~1032×, and an honest file of zeros reaches 1026×, so a ratio
202
+ * refuses real data while the total cap and the declared-size inflate are
203
+ * what actually stop a bomb); `only` reads a subset
204
+ * @returns {Map<string, Buffer>}
205
+ */
206
+ export function readZip(buf, { maxTotal = 2 * 1024 ** 3, maxRatio = Infinity, only = null } = {}) {
207
+ const entries = listZip(buf).filter((e) => !e.dir && (!only || only(e.name)));
208
+ const total = entries.reduce((a, e) => a + e.usize, 0);
209
+ if (total > maxTotal) throw fail("zip_bomb", `the archive expands to ${total} bytes, over the ${maxTotal}-byte cap`);
210
+ for (const e of entries) {
211
+ if (e.method === 8 && e.usize > Math.max(1024, e.csize * maxRatio)) {
212
+ throw fail("zip_bomb", `${e.name} claims to expand ${e.csize} → ${e.usize} bytes, over ${maxRatio}×`);
213
+ }
214
+ }
215
+ const files = new Map();
216
+ for (const e of entries) {
217
+ const p = e.offset;
218
+ if (p + 30 > buf.length || buf.readUInt32LE(p) !== SIG_LOCAL) throw fail("zip_corrupt", `${e.name}: its local header is damaged`);
219
+ const start = p + 30 + buf.readUInt16LE(p + 26) + buf.readUInt16LE(p + 28);
220
+ if (start + e.csize > buf.length) throw fail("zip_corrupt", `${e.name}: its data runs past the end`);
221
+ const body = buf.subarray(start, start + e.csize);
222
+ let data;
223
+ if (e.method === 0) {
224
+ if (e.csize !== e.usize) throw fail("zip_corrupt", `${e.name}: stored entry with two different sizes`);
225
+ data = Buffer.from(body);
226
+ } else {
227
+ try { data = zlib.inflateRawSync(body, { maxOutputLength: Math.max(1, e.usize) }); }
228
+ catch (err) { throw fail("zip_corrupt", `${e.name}: ${err.code === "ERR_BUFFER_TOO_LARGE" ? "inflates past its declared size" : "does not inflate"}`); }
229
+ if (data.length !== e.usize) throw fail("zip_corrupt", `${e.name}: inflated to ${data.length} bytes, declared ${e.usize}`);
230
+ }
231
+ if ((zlib.crc32(data) >>> 0) !== e.crc) throw fail("zip_crc", `${e.name}: CRC-32 does not match — the archive is damaged`);
232
+ files.set(e.name, data);
233
+ }
234
+ return files;
235
+ }
236
+
237
+ export default { writeZip, readZip, listZip, safeEntryName };
package/kernel/analyze.R CHANGED
@@ -44,7 +44,7 @@ suppressPackageStartupMessages(stopifnot(requireNamespace("jsonlite", quietly =
44
44
  # raise it knowingly. Clamped to [0, 60] so a typo cannot remove the bound —
45
45
  # and 0 is a real setting, meaning "scan nothing and report it truncated".
46
46
  ANALYZE_BUDGET <- local({
47
- v <- suppressWarnings(as.numeric(Sys.getenv("CARMAR_ANALYZE_BUDGET", "3")))
47
+ v <- suppressWarnings(as.numeric(Sys.getenv("BEATRINA_ANALYZE_BUDGET", "3")))
48
48
  if (!is.finite(v)) v <- 3
49
49
  max(0, min(v, 60))
50
50
  })