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/host/settings.mjs CHANGED
@@ -13,10 +13,10 @@
13
13
  // · An administrator's setting is NOT IN THIS FILE'S VOCABULARY AT ALL. It is
14
14
  // rejected on read and refused on write, always — never "env wins if
15
15
  // present", because that only guarantees anything when the env IS set, and
16
- // a deployment that never set CARMAR_ROOT is still one where a user must
16
+ // a deployment that never set BEATRINA_ROOT is still one where a user must
17
17
  // not invent one.
18
18
  //
19
- // The tempting alternative — one layered getter handed to carmarDeployment()
19
+ // The tempting alternative — one layered getter handed to beatrinaDeployment()
20
20
  // and the AI policy — is deliberately NOT built here either. It would put every
21
21
  // administrator variable one argument-passing mistake away from being
22
22
  // file-settable. Those read process.env directly; only the user-writable call
@@ -32,6 +32,7 @@ import fs from "node:fs";
32
32
 
33
33
  import path from "node:path";
34
34
  import { appId, ensureUserDir, privateModeHeld } from "./user-dirs.mjs";
35
+ import { envReader, mirrorEnv, setEnvName } from "./env-names.mjs";
35
36
 
36
37
  export const SETTINGS_VERSION = 1;
37
38
 
@@ -46,12 +47,19 @@ export const SETTINGS_VERSION = 1;
46
47
  export const SETTINGS = Object.freeze([
47
48
  // Connection loss is not permission to discard the R workspace. Timed
48
49
  // shutdown is opt-in; 0 retains an attached session until an explicit Quit.
49
- { key: "linger_seconds", env: "CARMAR_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
50
- { key: "history_enabled", env: "CARMAR_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
51
- { key: "analyze_budget_s", env: "CARMAR_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
52
- { key: "quarto_path", env: "CARMAR_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
53
- { key: "rscript_path", env: "CARMAR_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
54
- { key: "port", env: "CARMAR_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
50
+ { key: "linger_seconds", env: "BEATRINA_LINGER", kind: "number", default: 0, min: 0, max: 86400, effect: "live", label: "Keep R running after all pages disconnect" },
51
+ { key: "history_enabled", env: "BEATRINA_NO_HISTORY", kind: "bool", default: true, effect: "live", inverted: true, label: "Remember console history between sessions" },
52
+ { key: "analyze_budget_s", env: "BEATRINA_ANALYZE_BUDGET", kind: "number", default: 3, min: 0, max: 60, effect: "next_launch", label: "Code-intelligence time budget" },
53
+ { key: "quarto_path", env: "BEATRINA_QUARTO", kind: "path", default: "", effect: "live", label: "Quarto binary" },
54
+ { key: "rscript_path", env: "BEATRINA_RSCRIPT", kind: "path", default: "", effect: "restart_r", label: "R binary" },
55
+ { key: "port", env: "BEATRINA_PORT", kind: "number", default: 4747, min: 1024, max: 65535, effect: "next_launch", label: "Preferred port" },
56
+ // WHO IS WRITING. Machine scope, beside console history and the AI store,
57
+ // and deliberately NOT localStorage: every file:// page shares one origin,
58
+ // so a name kept there would be readable by any local HTML the user opens
59
+ // and would not follow them to a served page either. With no kernel there
60
+ // is no name, which is consistent — with no kernel there is no comment
61
+ // store to attribute anything in.
62
+ { key: "person_name", env: "BEATRINA_NAME", kind: "text", max: 120, default: "", effect: "live", label: "Your name" },
55
63
  ]);
56
64
 
57
65
  /**
@@ -60,37 +68,63 @@ export const SETTINGS = Object.freeze([
60
68
  * the refusal structural rather than a lookup someone can forget.
61
69
  */
62
70
  export const SETTINGS_ADMIN = Object.freeze([
63
- "CARMAR_MANAGED_CONFIG",
64
- "CARMAR_ROOT", "CARMAR_LOG", "CARMAR_LOG_AI_TEXT",
65
- "CARMAR_CRAN_MIRROR",
66
- "CARMAR_AI_LOCAL_ONLY", "CARMAR_AI_POLICY", "CARMAR_AI_PROVIDERS",
67
- "CARMAR_BIND", "CARMAR_HOSTS", "CARMAR_ORIGINS", "CARMAR_REQUIRE_ORIGIN",
68
- "CARMAR_TRUST_PROXY", "CARMAR_USER_HEADER", "CARMAR_TRUSTED_PROXY",
69
- "CARMAR_ALLOW_UNAUTHENTICATED", "CARMAR_PORT_STRICT",
70
- "CARMAR_UPDATE_FEED", "CARMAR_UPDATE_MIRROR", "CARMAR_UPDATE_OFFLINE_DIR",
71
- "CARMAR_UPDATE_MANAGED",
71
+ "BEATRINA_MANAGED_CONFIG",
72
+ "BEATRINA_ROOT", "BEATRINA_LOG", "BEATRINA_LOG_AI_TEXT",
73
+ "BEATRINA_CRAN_MIRROR",
74
+ "BEATRINA_AI_LOCAL_ONLY", "BEATRINA_AI_POLICY", "BEATRINA_AI_PROVIDERS",
75
+ "BEATRINA_BIND", "BEATRINA_HOSTS", "BEATRINA_ORIGINS", "BEATRINA_REQUIRE_ORIGIN",
76
+ "BEATRINA_TRUST_PROXY", "BEATRINA_USER_HEADER", "BEATRINA_TRUSTED_PROXY",
77
+ "BEATRINA_ALLOW_UNAUTHENTICATED", "BEATRINA_PORT_STRICT",
78
+ "BEATRINA_UPDATE_FEED", "BEATRINA_UPDATE_MIRROR", "BEATRINA_UPDATE_OFFLINE_DIR",
79
+ "BEATRINA_UPDATE_MANAGED",
72
80
  ]);
73
81
 
74
- export const MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
82
+ export const MANAGED_SETTINGS_SCHEMA = "beatrina-managed-settings-v1";
75
83
  export const MANAGED_SETTINGS_ENV = Object.freeze([...new Set([
76
84
  ...SETTINGS.map((s) => s.env),
77
- ...SETTINGS_ADMIN.filter((n) => n !== "CARMAR_MANAGED_CONFIG"),
85
+ ...SETTINGS_ADMIN.filter((n) => n !== "BEATRINA_MANAGED_CONFIG"),
78
86
  "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
79
87
  ])]);
80
88
 
89
+ // An administrator's file written for Beatrina 0.8.x: its schema string and
90
+ // its keys spell the old product name. Both are read; `managedRead` reports
91
+ // the values under the current names, and nothing writes the old ones.
92
+ // beatrina-names: keep-start
93
+ const LEGACY_MANAGED_SETTINGS_SCHEMA = "carmar-managed-settings-v1";
94
+ const LEGACY_MANAGED_KEY = /^CARMAR_/;
95
+ // beatrina-names: keep-end
96
+ // A key is reported as itself when the vocabulary knows it, else under its current spelling.
97
+ const currentKey = (k) => (MANAGED_SETTINGS_ENV.includes(k) ? k : k.replace(LEGACY_MANAGED_KEY, "BEATRINA_"));
98
+
81
99
  export const settingsEntry = (key) => SETTINGS.find((s) => s.key === key) || null;
82
100
 
83
- const sysEnv = (name, unset = "") => (process.env[name] == null ? unset : String(process.env[name]));
101
+ // Dual-read (host/env-names.mjs): every `env:` name in SETTINGS is answered
102
+ // under its older spelling too; the page (lib/settings/kernel-pane.js) keys its
103
+ // admin rows on the same names, in the same spelling.
104
+ const sysEnv = envReader(process.env);
84
105
 
85
106
  // ── managed configuration ───────────────────────────────────────────────────
86
107
 
87
- export function managedDefaultPath(env = sysEnv, platform = process.platform) {
88
- if (platform === "darwin") return "/Library/Application Support/CarmaR/managed-settings.json";
108
+ /** The places an administrator's file is looked for on this platform, the current one first. */
109
+ export function managedDefaultPaths(env = sysEnv, platform = process.platform) {
110
+ if (platform === "darwin") {
111
+ return ["/Library/Application Support/Beatrina/managed-settings.json",
112
+ "/Library/Application Support/CarmaR/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
113
+ }
89
114
  if (platform === "win32") {
90
115
  const base = String(env("ProgramData", "")).trim() || "C:/ProgramData";
91
- return path.join(base, "CarmaR", "managed-settings.json");
116
+ return [path.join(base, "Beatrina", "managed-settings.json"),
117
+ path.join(base, "CarmaR", "managed-settings.json")]; // beatrina-names: keep — a file deployed for 0.8.x
92
118
  }
93
- return "/etc/carmar/managed-settings.json";
119
+ return ["/etc/beatrina/managed-settings.json",
120
+ "/etc/carmar/managed-settings.json"]; // beatrina-names: keep — a file deployed for 0.8.x
121
+ }
122
+
123
+ /** The administrator's file: the current path, unless only the older one exists. */
124
+ export function managedDefaultPath(env = sysEnv, platform = process.platform) {
125
+ const [current, ...older] = managedDefaultPaths(env, platform);
126
+ if (fs.existsSync(current)) return current;
127
+ return older.find((p) => p !== current && fs.existsSync(p)) || current;
94
128
  }
95
129
 
96
130
  /**
@@ -120,12 +154,12 @@ export function managedProtected(file, platform = process.platform) {
120
154
  export function managedRead(file, { required = false, protection = managedProtected } = {}) {
121
155
  const empty = { status: "missing", path: file, values: {}, errors: [] };
122
156
  if (!fs.existsSync(file)) {
123
- if (required) return { ...empty, status: "invalid", errors: ["CARMAR_MANAGED_CONFIG names a file that does not exist."] };
157
+ if (required) return { ...empty, status: "invalid", errors: ["BEATRINA_MANAGED_CONFIG names a file that does not exist."] };
124
158
  return empty;
125
159
  }
126
160
  if (protection(file) !== true) {
127
161
  return { ...empty, status: "insecure",
128
- errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; CarmaR refused it."] };
162
+ errors: ["The managed settings file is writable by the current account, is a symlink, or is not administrator-owned; Beatrina refused it."] };
129
163
  }
130
164
  let size = NaN;
131
165
  try { size = fs.statSync(file).size; } catch { size = NaN; }
@@ -138,12 +172,12 @@ export function managedRead(file, { required = false, protection = managedProtec
138
172
  const rootNames = plain ? Object.keys(raw) : null;
139
173
  const settingsPlain = plain && raw.settings && typeof raw.settings === "object" && !Array.isArray(raw.settings);
140
174
  if (!rootNames || rootNames.length !== 2 || !rootNames.includes("schema") || !rootNames.includes("settings")
141
- || raw.schema !== MANAGED_SETTINGS_SCHEMA || !settingsPlain) {
175
+ || ![MANAGED_SETTINGS_SCHEMA, LEGACY_MANAGED_SETTINGS_SCHEMA].includes(raw.schema) || !settingsPlain) {
142
176
  return { ...empty, status: "invalid",
143
177
  errors: [`The managed settings file must use schema ${MANAGED_SETTINGS_SCHEMA} with one settings object.`] };
144
178
  }
145
179
  const keys = Object.keys(raw.settings);
146
- const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(k));
180
+ const unknown = keys.filter((k) => !MANAGED_SETTINGS_ENV.includes(currentKey(k)));
147
181
  const bad = keys.filter((k) => {
148
182
  const v = raw.settings[k];
149
183
  return !(typeof v === "string" && v.length > 0 && !/[\r\n\t]/.test(v));
@@ -153,7 +187,7 @@ export function managedRead(file, { required = false, protection = managedProtec
153
187
  if (bad.length) errors.push(`Managed settings must be non-empty single-line strings: ${bad.join(", ")}.`);
154
188
  if (errors.length) return { ...empty, status: "invalid", errors };
155
189
  const values = {};
156
- for (const k of keys) values[k] = raw.settings[k];
190
+ for (const k of keys) values[currentKey(k)] = raw.settings[k];
157
191
  return { status: "ok", path: file, values, errors: [] };
158
192
  }
159
193
 
@@ -162,11 +196,13 @@ export function managedRead(file, { required = false, protection = managedProtec
162
196
  * every subsystem that follows resolves under the ordinary env-wins rule.
163
197
  */
164
198
  export function managedEnvironment({ env = sysEnv, file = null, protection = managedProtected, apply = true } = {}) {
165
- const explicit = String(env("CARMAR_MANAGED_CONFIG", "")).trim();
199
+ const explicit = String(env("BEATRINA_MANAGED_CONFIG", "")).trim();
166
200
  const target = file || (explicit || managedDefaultPath(env));
167
201
  const result = managedRead(target, { required: Boolean(explicit), protection });
168
202
  if (apply && result.status === "ok") {
169
- for (const [k, v] of Object.entries(result.values)) process.env[k] = v;
203
+ // Under BOTH spellings: a managed value must be what every reader sees,
204
+ // whichever prefix it asks for.
205
+ for (const [k, v] of Object.entries(result.values)) setEnvName(process.env, k, v);
170
206
  }
171
207
  return result;
172
208
  }
@@ -215,6 +251,18 @@ export function validateSetting(key, value) {
215
251
  if (entry.max != null && value > entry.max) return { ok: false, reason: `must be at most ${entry.max}` };
216
252
  return { ok: true, value: Number(value) };
217
253
  }
254
+ if (entry.kind === "text") {
255
+ if (typeof value !== "string") return { ok: false, reason: "must be a string" };
256
+ const text = value.trim();
257
+ const cap = entry.max == null ? 200 : entry.max;
258
+ if (text.length > cap) return { ok: false, reason: `must be at most ${cap} characters` };
259
+ // A control character is not a typo here: this value is written into
260
+ // JSON-lines logs (a comment's author), and a newline in one of those
261
+ // forges a record. `ai-audit` strips them for the same reason; a setting
262
+ // can refuse instead, because a person typed it and can be told.
263
+ if (/[\u0000-\u001f\u007f]/.test(text)) return { ok: false, reason: "must not contain control characters" };
264
+ return { ok: true, value: text };
265
+ }
218
266
  // path
219
267
  if (typeof value !== "string") return { ok: false, reason: "must be a string" };
220
268
  // An empty path means "use the ladder" — a legitimate value, not a missing
@@ -247,7 +295,7 @@ export function readSettingsFile(file = settingsPath()) {
247
295
  return { ...empty, status: "invalid", error: "The settings file could not be read as JSON." };
248
296
  }
249
297
  if (Math.trunc(Number(raw.version || 0)) !== SETTINGS_VERSION) {
250
- return { ...empty, status: "invalid", error: "The settings file is a version this CarmaR does not know." };
298
+ return { ...empty, status: "invalid", error: "The settings file is a version this Beatrina does not know." };
251
299
  }
252
300
  const block = raw.settings == null ? {} : raw.settings;
253
301
  if (typeof block !== "object" || Array.isArray(block)) {
@@ -298,7 +346,7 @@ export function resolveSettings({ env = sysEnv, file = null } = {}) {
298
346
  if (fromEnv) {
299
347
  source = "env";
300
348
  if (s.kind === "bool") {
301
- // CARMAR_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
349
+ // BEATRINA_NO_HISTORY is a NEGATIVE flag — any value means "off" — while
302
350
  // the file and the UI hold the positive. Translating happens here, once.
303
351
  value = s.inverted ? false : true;
304
352
  } else if (s.kind === "number") {
@@ -330,10 +378,10 @@ export const settingsValue = (resolved, key) => (resolved.settings[key] ? resolv
330
378
  export function settingsChildEnv(resolved) {
331
379
  const out = {};
332
380
  const q = settingsValue(resolved, "quarto_path");
333
- if (typeof q === "string" && q.length) out.CARMAR_QUARTO = q;
381
+ if (typeof q === "string" && q.length) out.BEATRINA_QUARTO = q;
334
382
  const b = settingsValue(resolved, "analyze_budget_s");
335
- if (typeof b === "number") out.CARMAR_ANALYZE_BUDGET = String(b);
336
- return out;
383
+ if (typeof b === "number") out.BEATRINA_ANALYZE_BUDGET = String(b);
384
+ return mirrorEnv(out); // a child that still reads BEATRINA_QUARTO sees it
337
385
  }
338
386
 
339
387
  /**
@@ -359,7 +407,7 @@ export function createSettings({ env = sysEnv, applyManaged = true } = {}) {
359
407
  /** Which R the worker, the analyzer and every job child should run. */
360
408
  rscriptPreference() {
361
409
  const chosen = api.value("rscript_path");
362
- return typeof chosen === "string" && chosen.length ? chosen : String(env("CARMAR_RSCRIPT", ""));
410
+ return typeof chosen === "string" && chosen.length ? chosen : String(env("BEATRINA_RSCRIPT", ""));
363
411
  },
364
412
  /**
365
413
  * The idle clock. The same safe default applies locally and behind a proxy:
@@ -8,12 +8,13 @@
8
8
  // puts `update_available` on /health and on the ready frame only when the recorded version is newer
9
9
  // than the build that is running, and the page says it (lib/info-pane.js, lib/doctor.js).
10
10
  //
11
- // CARMAR_NO_UPDATE_CHECK=1 or BEATRINA_NO_UPDATE_CHECK=1 turns the reading off as well as the asking
11
+ // BEATRINA_NO_UPDATE_CHECK=1 (or its older spelling) turns the reading off as well as the asking
12
12
  // (the battery runner sets the first), so no test kernel reports a version some real run recorded.
13
13
 
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { rUserDir } from "./user-dirs.mjs";
17
+ import { readEnv } from "./env-names.mjs";
17
18
 
18
19
  /** -1, 0, 1 for semver-shaped versions; a pre-release sorts before its release; nonsense compares equal. */
19
20
  export function compareVersions(a, b) {
@@ -38,7 +39,7 @@ export function updateRecordFile(app) {
38
39
 
39
40
  /** The check is off: nothing is asked and nothing is read. */
40
41
  export function updateCheckOff(env = process.env) {
41
- return env.BEATRINA_NO_UPDATE_CHECK === "1" || env.CARMAR_NO_UPDATE_CHECK === "1";
42
+ return readEnv(env, "NO_UPDATE_CHECK") === "1";
42
43
  }
43
44
 
44
45
  /**
@@ -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));
@@ -46,7 +46,8 @@ export const WORKER_DEADLINES = Object.freeze({
46
46
  package_help: WORKER_DEADLINE_FAST, project_status: WORKER_DEADLINE_FAST, rm: WORKER_DEADLINE_FAST,
47
47
  debug_breaks: WORKER_DEADLINE_FAST, files: WORKER_DEADLINE_FAST, mkdir: WORKER_DEADLINE_FAST,
48
48
  renamepath: WORKER_DEADLINE_FAST, sniff: WORKER_DEADLINE_FAST,
49
- 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,
50
51
  format: WORKER_DEADLINE_SLOW, doctor: WORKER_DEADLINE_SLOW, readfile: WORKER_DEADLINE_SLOW,
51
52
  writefile: WORKER_DEADLINE_SLOW, writefiles_atomic: WORKER_DEADLINE_SLOW,
52
53
  copypath: WORKER_DEADLINE_SLOW, deletepath: WORKER_DEADLINE_SLOW, revealpath: WORKER_DEADLINE_SLOW,
@@ -331,10 +332,29 @@ export class WorkerPlane extends EventEmitter {
331
332
  this.gaveUp = false;
332
333
  this.notice = null;
333
334
  this.resumePending = keepTokenOk(resume) ? resume : null;
334
- await this.start();
335
+ try { await this.start(); } catch (e) { this.refuseStart(e); throw e; }
335
336
  }
336
337
 
337
- /** A token for the next ready worker (a handoff successor's CARMAR_RESUME_TOKEN). */
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). */
338
358
  setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
339
359
 
340
360
  /**
@@ -500,12 +520,12 @@ export class WorkerPlane extends EventEmitter {
500
520
  try { this.engine.debugCommand(action, expr); } catch { /* the exit event reports it */ }
501
521
  return undefined;
502
522
  }
503
- const line = { continue: "c", over: "n", into: "s", out: "f", where: ".carmar_debug_where()",
504
- 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;
505
525
  if (line == null || /[\n\r]/.test(line) || line.length > 4000) return err("bad debug command");
506
526
  this.audit("debug_cmd", { action });
507
527
  try { this.engine.console(line); } catch { /* the exit event reports it */ }
508
- 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 */ } }
509
529
  return undefined;
510
530
  }
511
531
 
@@ -651,6 +671,14 @@ export class WorkerPlane extends EventEmitter {
651
671
  let started = null;
652
672
  try { started = this.start(); } catch (e) { this.audit("worker-respawn-failed", { attempt, detail: e.message }); }
653
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
+ });
654
682
  this.audit("worker-respawn", { attempt });
655
683
  this.emit("broadcast", enc({ type: "worker-restarting", engine: this.name, attempt }));
656
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