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
@@ -18,12 +18,13 @@
18
18
  // POST /session/upgrade and POST /session/restart. A port of serve.R's
19
19
  // handoff_refusal / session_handoff_begin / session_handoff_spawn: a successor
20
20
  // supervisor is started on the SAME port from the files beside this one (after
21
- // an in-place upgrade, the installed build's), every page is told where its
22
- // successor's notebook is, the worker's whole tree is killed, and this process
23
- // exits. The successor's R is FRESH — no save.image() bridge, by decision
21
+ // an in-place upgrade, the installed build's) and must PROVE it can run before
22
+ // anything of this session is touched (handoffBegin: prepare, then retire);
23
+ // then every page is told where its successor's notebook is, the worker's whole
24
+ // tree is killed, and this process exits. The successor's R is FRESH — no save.image() bridge, by decision
24
25
  // (docs/upgrade.md); a restart says so and does not pretend otherwise.
25
26
  //
26
- // `CARMAR_SESSION_BASE` is Beatrix's only addition to the wire's behaviour
27
+ // `BEATRINA_SESSION_BASE` is Beatrix's only addition to the wire's behaviour
27
28
  // here: serve.R hardcodes the 4747..4947 scan, and a TEST may not bind 4747
28
29
  // (FORK.md), so the base is readable from the environment and defaults to
29
30
  // 4747 — the published-page port, fixed because browsers key storage to the
@@ -37,6 +38,8 @@ import { spawn, execFileSync } from "node:child_process";
37
38
  import { fileURLToPath } from "node:url";
38
39
  import { PAGE_ONLY_CLASSES, secureToken } from "../server.mjs";
39
40
  import { stateDir } from "../user-dirs.mjs";
41
+ import { mirrorEnv, unsetEnv } from "../env-names.mjs";
42
+ import { runtimeReadDirs } from "../runtime-dir.mjs";
40
43
  import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
41
44
 
42
45
  export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
@@ -46,28 +49,39 @@ export const SESSION_BAND = 201;
46
49
 
47
50
  const RUNTIME_NAME = /^kernel-([0-9]+)\.json$/;
48
51
 
49
- /** The runtime directory both supervisors write their records into. */
50
- export function runtimeDir(env) {
51
- return env("CARMAR_RUNTIME_DIR") || path.join(os.homedir(), ".carmar", "run");
52
+ /**
53
+ * The first port of the session band: BEATRINA_SESSION_BASE when it names one, else 4747. ONE
54
+ * definition — the sibling a page starts (below) and the host's own choosePort (main.mjs) read
55
+ * it, so the two starters agree on where a second session lands.
56
+ */
57
+ export function sessionBase(env) {
58
+ const given = Number.parseInt(env("BEATRINA_SESSION_BASE", "4747"), 10);
59
+ return Number.isInteger(given) && given >= 1024 && given <= 65000 ? given : 4747;
52
60
  }
53
61
 
54
- /** Every `kernel-<port>.json` in the runtime directory, parsed; unreadable ones dropped. */
62
+ /**
63
+ * Every `kernel-<port>.json` a kernel may have left, parsed; unreadable ones
64
+ * dropped. Read from the current directory AND the legacy `~/.carmar/run` (beatrina-names: keep)
65
+ * (a kernel started before the rename, or by an older launcher, is still a
66
+ * session); one record per port, the current directory's winning.
67
+ */
55
68
  export function runtimeRecords(env) {
56
- const dir = runtimeDir(env);
57
- let names = [];
58
- try { names = fs.readdirSync(dir); } catch { return []; }
59
- const out = [];
60
- for (const name of names) {
61
- const m = RUNTIME_NAME.exec(name);
62
- if (!m) continue;
63
- let rec = null;
64
- try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { rec = null; }
65
- if (!rec || typeof rec !== "object" || rec.port == null || rec.url == null) continue;
66
- const port = Number.parseInt(rec.port, 10);
67
- if (!Number.isInteger(port)) continue;
68
- out.push({ ...rec, port });
69
+ const byPort = new Map();
70
+ for (const dir of runtimeReadDirs(env)) {
71
+ let names = [];
72
+ try { names = fs.readdirSync(dir); } catch { continue; }
73
+ for (const name of names) {
74
+ const m = RUNTIME_NAME.exec(name);
75
+ if (!m) continue;
76
+ let rec = null;
77
+ try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { rec = null; }
78
+ if (!rec || typeof rec !== "object" || rec.port == null || rec.url == null) continue;
79
+ const port = Number.parseInt(rec.port, 10);
80
+ if (!Number.isInteger(port) || byPort.has(port)) continue;
81
+ byPort.set(port, { ...rec, port });
82
+ }
69
83
  }
70
- return out.sort((a, b) => a.port - b.port);
84
+ return [...byPort.values()].sort((a, b) => a.port - b.port);
71
85
  }
72
86
 
73
87
  /**
@@ -78,15 +92,22 @@ export function runtimeRecords(env) {
78
92
  * connected page for a full minute per port.
79
93
  */
80
94
  export async function healthy(url, timeoutMs = 1000) {
95
+ return (await healthOf(url, timeoutMs)) != null;
96
+ }
97
+
98
+ /** The /health frame of a kernel that is answering on `url` (`"ok":true`), else null. Same one-second rule. */
99
+ export async function healthOf(url, timeoutMs = 1000) {
81
100
  const target = `${String(url).replace(/\/+$/, "")}/health`;
82
101
  const ctrl = new AbortController();
83
102
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
84
103
  try {
85
104
  const res = await fetch(target, { signal: ctrl.signal, cache: "no-store" });
86
- if (!res.ok) return false;
87
- return (await res.text()).includes('"ok":true');
105
+ if (!res.ok) return null;
106
+ const text = await res.text();
107
+ if (!text.includes('"ok":true')) return null;
108
+ try { const h = JSON.parse(text); return h && h.ok === true ? h : null; } catch { return null; }
88
109
  } catch {
89
- return false;
110
+ return null;
90
111
  } finally {
91
112
  clearTimeout(timer);
92
113
  }
@@ -181,10 +202,9 @@ export function createPlane({ root, env, audit }) {
181
202
  const siblingArgs = mainPath ? [mainPath] : [];
182
203
  let versionsCache = null;
183
204
 
184
- const sessionBase = (() => {
185
- const given = Number.parseInt(env("CARMAR_SESSION_BASE", "4747"), 10);
186
- return Number.isInteger(given) && given >= 1024 && given <= 65000 ? given : 4747;
187
- })();
205
+ const base = sessionBase(env);
206
+ /** Ports chosen by a sibling start that has not finished yet. */
207
+ const pendingPorts = new Set();
188
208
 
189
209
  /**
190
210
  * Start another supervisor on the next free adjacent port.
@@ -192,47 +212,73 @@ export function createPlane({ root, env, audit }) {
192
212
  * Adjacent and stable on purpose (browsers key storage to the origin), and
193
213
  * DETACHED — closing this session must not close that one. The port is a
194
214
  * PREFERENCE, not a promise: between the probe and the child's own bind the
195
- * port can go, so what is waited for is a NEW live port, whichever appears.
196
- * serve.R learned that too: waiting for the exact port reported failure for
197
- * a session that had started perfectly well, and orphaned it.
215
+ * port can go, so the answer is the port the child ANNOUNCES in its runtime
216
+ * record, never the one asked for. serve.R learned that too: waiting for the
217
+ * exact port reported failure for a session that had started perfectly well,
218
+ * and orphaned it.
219
+ *
220
+ * The session waited for is THIS child's, identified by a launch id carried
221
+ * in its environment (BEATRINA_LAUNCH_ID → the record's `launch`) and by its
222
+ * pid. Until 0.9.6 it was "the first live port that was not there before",
223
+ * so two New Session clicks at once — or any launch from the menu bar in the
224
+ * same seconds — could each be handed the other's session (hardening.md 3).
225
+ *
226
+ * Resolves {port, url} or {error, log}: an early exit or a timeout is said
227
+ * with the tail of the child's own log, not folded into a bare null.
198
228
  */
199
229
  const startSibling = async (ctx) => {
200
230
  const own = ctx.port();
201
231
  const used = new Set(await liveSessionPorts(env, own));
232
+ // A port another start in THIS process has chosen is taken too: two
233
+ // requests probing at once both saw the same free port, and the second
234
+ // child found it bound (the concurrent case in test/host-sessions).
202
235
  let chosen = null;
203
- for (let p = sessionBase; p <= sessionBase + SESSION_BAND - 1; p += 1) {
204
- if (!used.has(p)) { chosen = p; break; }
236
+ for (let p = base; p <= base + SESSION_BAND - 1; p += 1) {
237
+ if (!used.has(p) && !pendingPorts.has(p)) { chosen = p; break; }
205
238
  }
206
- if (chosen == null) return null;
207
- const before = new Set(await liveSessionPorts(env, own));
239
+ if (chosen == null) return { error: `Every port from ${base} to ${base + SESSION_BAND - 1} holds a session.` };
240
+ pendingPorts.add(chosen);
241
+ try { return await startOn(chosen); } finally { pendingPorts.delete(chosen); }
242
+ };
243
+
244
+ /** The start itself, on a port already reserved by startSibling. */
245
+ const startOn = async (chosen) => {
246
+ const launch = `sibling-${secureToken(12).replace(/[^A-Za-z0-9]/g, "").slice(0, 12)}`;
208
247
  // The pipes go to a FILE, not to a pipe nobody reads: a detached sibling
209
248
  // wedges once ~64 KB of stderr accumulates in an unread buffer.
249
+ const logPath = path.join(os.tmpdir(), `beatrix-session-${process.pid}-${launch}.log`);
210
250
  let logFd = "ignore";
211
- try {
212
- logFd = fs.openSync(path.join(os.tmpdir(), `beatrix-session-${process.pid}-${chosen}.log`), "a");
213
- } catch { logFd = "ignore"; }
251
+ try { logFd = fs.openSync(logPath, "a"); } catch { logFd = "ignore"; }
252
+ const logTail = () => {
253
+ try { return fs.readFileSync(logPath, "utf8").trim().split("\n").slice(-6).join("\n"); } catch { return ""; }
254
+ };
214
255
  let child;
215
256
  try {
216
257
  child = spawn(process.execPath, siblingArgs, {
217
258
  cwd: root,
218
- env: { ...process.env, CARMAR_PORT: String(chosen) },
259
+ env: mirrorEnv({ ...process.env, BEATRINA_PORT: String(chosen), BEATRINA_LAUNCH_ID: launch, BEATRINA_LAUNCH_VIA: "session-menu" }),
219
260
  detached: true,
220
261
  stdio: ["ignore", logFd, logFd],
221
262
  });
222
263
  child.unref();
223
- } catch { return null; }
264
+ } catch (e) {
265
+ return { error: `The session could not be started: ${e.message}` };
266
+ } finally {
267
+ if (typeof logFd === "number") { try { fs.closeSync(logFd); } catch { /* the child holds its own copy */ } }
268
+ }
269
+ let spawnError = null;
270
+ child.on("error", (e) => { spawnError = e; });
224
271
  const deadline = Date.now() + 60000;
225
272
  while (Date.now() < deadline) {
226
- const fresh = (await liveSessionPorts(env, own)).filter((p) => !before.has(p));
227
- if (fresh.length) {
228
- const got = fresh[0];
229
- const rec = runtimeRecords(env).find((r) => r.port === got);
230
- return { port: got, url: rec ? rec.url : `http://127.0.0.1:${got}/` };
273
+ const rec = runtimeRecords(env).find((r) => r.launch === launch && Number(r.pid) === child.pid);
274
+ if (rec && rec.url && await healthy(rec.url)) return { port: rec.port, url: rec.url, launch };
275
+ if (spawnError) return { error: `The session could not be started: ${spawnError.message}`, log: logTail() };
276
+ if (child.exitCode !== null || child.signalCode !== null) {
277
+ return { error: `The new session stopped before it was ready (${child.signalCode || `exit ${child.exitCode}`}).`, log: logTail() };
231
278
  }
232
- if (child.exitCode !== null || child.signalCode !== null) return null;
233
279
  await new Promise((r) => setTimeout(r, 250));
234
280
  }
235
- return null;
281
+ return { error: "The new session did not become ready within 60 seconds.", log: logTail() };
236
282
  };
237
283
 
238
284
  // ── the handoff ─────────────────────────────────────────────────────────
@@ -242,12 +288,12 @@ export function createPlane({ root, env, audit }) {
242
288
  const handoffRefusal = (ctx, mode, force) => {
243
289
  const refuse = (error, reason) => ({ error, reason });
244
290
  const installed = ctx.installedBuild();
245
- if (!installed || installed === "unknown") return refuse("This kernel cannot tell which CarmaR is installed.", "unknown-install");
291
+ if (!installed || installed === "unknown") return refuse("This kernel cannot tell which Beatrina is installed.", "unknown-install");
246
292
  if (mode === "upgrade" && installed === ctx.kernelBuild) {
247
- return refuse(`This session already runs the installed CarmaR ${installed}.`, "same-build");
293
+ return refuse(`This session already runs the installed Beatrina ${installed}.`, "same-build");
248
294
  }
249
295
  if (!ctx.notebookPage(installed)) {
250
- return refuse(`The installed CarmaR ${installed} has no notebook page beside this kernel.`, "no-page");
296
+ return refuse(`The installed Beatrina ${installed} has no notebook page beside this kernel.`, "no-page");
251
297
  }
252
298
  // An attempt that has spawned owns the port until this process exits.
253
299
  if (handoff) return refuse("A session restart is already under way.", "under-way");
@@ -269,7 +315,24 @@ export function createPlane({ root, env, audit }) {
269
315
  return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
270
316
  };
271
317
 
272
- const handoffBegin = (ctx, mode = "upgrade", force = false, resume = null) => {
318
+ /**
319
+ * Begin a handoff. TWO PHASES (docs/runplan.md, "Two-phase handoff"):
320
+ *
321
+ * 1. PREPARE — the successor is started with a ready file to write. It reads its protocol and
322
+ * build, finds its page and its worker, and runs its R once, all BEFORE it waits for the
323
+ * port (host/main.mjs, handoffPreflight). Nothing of this session has been touched: pages
324
+ * have not been told, workers are running, the port is ours.
325
+ * 2. RETIRE — only once the successor says `ok`: pages are told where it is, workers are
326
+ * stopped, this process leaves and the successor takes the port.
327
+ *
328
+ * Until 0.9.5 there was one phase: spawn, kill the workers, exit 300 ms later — so a successor
329
+ * that could not start (a broken install, a missing page, an R that would not run) took the
330
+ * session down with it. A successor that fails or stays silent is now killed and the session
331
+ * carries on, and the reply says why.
332
+ *
333
+ * @returns {Promise<object>} the reply — `{ok:true, …}` or `{ok:false, error, reason}`
334
+ */
335
+ const handoffBegin = async (ctx, mode = "upgrade", force = false, resume = null) => {
273
336
  mode = mode === "restart" ? "restart" : "upgrade";
274
337
  force = force === true && mode === "restart";
275
338
  const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
@@ -284,56 +347,124 @@ export function createPlane({ root, env, audit }) {
284
347
  const started = ctx.startedSeconds;
285
348
  handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
286
349
  audit(auditName, { from: ctx.kernelBuild, to: installed, force });
350
+
351
+ const prepared = await handoffPrepare(ctx);
352
+ if (!prepared.ok) {
353
+ handoff = null;
354
+ audit("session-upgrade-failed", { reason: prepared.reason, detail: prepared.error });
355
+ return { ok: false, error: prepared.error, reason: prepared.reason };
356
+ }
357
+ // R may have been given a chunk while the successor prepared. Retiring now would kill it; a
358
+ // forced restart was asked for exactly that, anything else is refused and the successor goes.
359
+ const pl = ctx.plane;
360
+ if (!force && ((pl.running && pl.running.size > 0) || (pl.primary ? pl.primary.active != null : pl.active != null))) {
361
+ killSuccessor(prepared.child);
362
+ handoff = null;
363
+ audit(`${auditName}-refused`, { reason: "busy", force, phase: "prepared" });
364
+ return { ok: false, error: "R became busy while the new session was being prepared — wait for the running chunk, or interrupt it, then try again.", reason: "busy" };
365
+ }
287
366
  // Every page learns where its successor's notebook is — over the gated
288
367
  // socket only; the capability never appears in /health. `started` is this
289
368
  // kernel's boot time: a page recognises its successor by a LATER one, the
290
369
  // only test that also works when the build does not change.
291
370
  const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
292
371
  ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
293
- const spawned = handoffSpawn(ctx);
294
- if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
372
+ handoffRetire(ctx);
295
373
  return { ok: true, from: ctx.kernelBuild, to: installed, page, started, mode, force };
296
374
  };
297
375
 
298
- const handoffAbandon = (ctx, reason) => {
299
- audit("session-upgrade-failed", { reason });
300
- handoff = null;
301
- const told = ctx.enc({ type: "session-upgrade-failed", error: reason });
302
- ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
376
+ /** A successor that will not be used: its whole group goes, since it was started detached. */
377
+ const killSuccessor = (child) => {
378
+ if (!child || !child.pid) return;
379
+ try { process.kill(-child.pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch { /* gone */ } }
380
+ };
381
+
382
+ /** How long a successor may take to prove it can run. */
383
+ const prepareSeconds = () => {
384
+ const v = Number(env("BEATRINA_HANDOFF_PREPARE_S", "20"));
385
+ return Number.isFinite(v) && v > 0 ? v : 20;
303
386
  };
304
387
 
305
- /** Start the successor on this port, kill the worker tree, and leave. */
306
- const handoffSpawn = (ctx) => {
388
+ /**
389
+ * Phase 1: start the successor on this port and wait for its ready file.
390
+ * @returns {Promise<{ok:true, child:object}|{ok:false, error:string, reason:string}>}
391
+ */
392
+ const handoffPrepare = async (ctx) => {
307
393
  const h = handoff;
308
394
  const dir = stateDir();
309
395
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the log is a courtesy */ }
310
396
  const port = ctx.port();
311
397
  const record = ctx.runtimeRecord();
312
- const childEnv = { ...process.env,
313
- CARMAR_PORT: String(port), CARMAR_WAIT_PORT: "30",
398
+ const readyFile = path.join(dir, `handoff-ready-${port}-${process.pid}-${secureToken(8)}.json`);
399
+ try { fs.rmSync(readyFile, { force: true }); } catch { /* not there */ }
400
+ // Canonical names, mirrored at the spawn so an installed Beatrina-era host
401
+ // (one that reads only CARMAR_*) starts as a successor too.
402
+ const childEnv = unsetEnv({ ...process.env,
403
+ BEATRINA_PORT: String(port), BEATRINA_WAIT_PORT: "30",
314
404
  // A successor that quietly moved to a free port would leave the page
315
405
  // polling a dead one; strict means it refuses and its log says why.
316
- CARMAR_PORT_STRICT: "1",
317
- CARMAR_FILE_LAUNCH_CAP: h.cap, CARMAR_HANDOFF_FROM: ctx.kernelBuild,
318
- CARMAR_SESSION_TITLE: record.title || "",
319
- CARMAR_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
406
+ BEATRINA_PORT_STRICT: "1",
407
+ BEATRINA_FILE_LAUNCH_CAP: h.cap, BEATRINA_HANDOFF_FROM: ctx.kernelBuild,
408
+ BEATRINA_HANDOFF_READY: readyFile,
409
+ BEATRINA_SESSION_TITLE: record.title || "",
410
+ BEATRINA_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
320
411
  // Only a token travels, never a path; the successor re-checks it.
321
- CARMAR_RESUME_TOKEN: h.resume || "" };
322
- if (record.listen) childEnv.CARMAR_LISTEN = "1"; else delete childEnv.CARMAR_LISTEN;
412
+ BEATRINA_RESUME_TOKEN: h.resume || "" }, "LISTEN");
413
+ if (record.listen) childEnv.BEATRINA_LISTEN = "1";
414
+ const logPath = path.join(dir, `kernel-handoff-${port}.log`);
415
+ // The log is appended across attempts: only what THIS successor wrote is quoted.
416
+ let logFrom = 0;
417
+ try { logFrom = fs.statSync(logPath).size; } catch { logFrom = 0; }
323
418
  let out = "ignore";
324
- try { out = fs.openSync(path.join(dir, `kernel-handoff-${port}.log`), "a"); } catch { out = "ignore"; }
419
+ try { out = fs.openSync(logPath, "a"); } catch { out = "ignore"; }
325
420
  let child = null;
326
421
  try {
327
422
  const { cmd, args } = successorCommand();
328
- child = spawn(cmd, args, { env: childEnv, stdio: ["ignore", out, out], detached: true });
423
+ child = spawn(cmd, args, { env: mirrorEnv(childEnv), stdio: ["ignore", out, out], detached: true });
329
424
  child.unref();
425
+ child.on("error", () => { /* reported through exitCode / the deadline below */ });
330
426
  } catch { child = null; }
331
427
  if (typeof out === "number") { try { fs.closeSync(out); } catch { /* closed */ } }
332
- if (!child || !child.pid) { handoffAbandon(ctx, "The installed CarmaR could not be started."); return false; }
428
+ if (!child || !child.pid) return { ok: false, error: "The installed Beatrina could not be started.", reason: "spawn" };
333
429
  audit("session-upgrade-spawned", { to: h.to, pid: child.pid, mode: h.mode });
334
- // Spawn first, kill second: the worker's whole process GROUP goes down only
335
- // once the successor exists, so a failed spawn leaves the session untouched.
336
- // The group kill reaches the shell grandchildren a hung R never signals.
430
+
431
+ // The sentence a failed successor said about itself: its own "Beatrina …" line, else the first
432
+ // error line, else the last line. A stack's LAST line is a frame of Node's loader, not a reason.
433
+ const logTail = () => {
434
+ let lines = [];
435
+ try { lines = fs.readFileSync(logPath).subarray(logFrom).toString("utf8").split("\n").map((l) => l.trim()).filter(Boolean); } catch { return ""; }
436
+ return lines.find((l) => /^Beatrina\b/.test(l)) || lines.find((l) => /Error\b/.test(l)) || lines[lines.length - 1] || "";
437
+ };
438
+ const deadline = Date.now() + prepareSeconds() * 1000;
439
+ try {
440
+ while (Date.now() < deadline) {
441
+ let said = null;
442
+ try { said = JSON.parse(fs.readFileSync(readyFile, "utf8")); } catch { said = null; }
443
+ if (said && said.ok === true) {
444
+ audit("session-upgrade-prepared", { to: h.to, pid: child.pid, build: said.build || "" });
445
+ return { ok: true, child };
446
+ }
447
+ if (said && said.ok === false) {
448
+ killSuccessor(child);
449
+ return { ok: false, error: `The installed Beatrina ${h.to} could not take over this session: ${said.error || "it refused to start"}. This session is untouched.`, reason: "successor" };
450
+ }
451
+ if (child.exitCode !== null || child.signalCode !== null) {
452
+ const tail = logTail();
453
+ return { ok: false, error: `The installed Beatrina ${h.to} exited before it was ready${tail ? ` (${tail.slice(0, 300)})` : ""}. This session is untouched.`, reason: "successor" };
454
+ }
455
+ await new Promise((r) => setTimeout(r, 100));
456
+ }
457
+ killSuccessor(child);
458
+ return { ok: false, error: `The installed Beatrina ${h.to} did not say it was ready within ${prepareSeconds()} seconds. This session is untouched.`, reason: "successor-timeout" };
459
+ } finally {
460
+ try { fs.rmSync(readyFile, { force: true }); } catch { /* gone */ }
461
+ }
462
+ };
463
+
464
+ /** Phase 2: stop the workers and leave; the prepared successor is waiting for the port. */
465
+ const handoffRetire = (ctx) => {
466
+ // The worker's whole process GROUP goes down only once the successor has
467
+ // proved it can run. The group kill reaches the shell grandchildren a hung R never signals.
337
468
  const planes = ctx.plane.planes ? [...ctx.plane.planes.values()] : [ctx.plane];
338
469
  for (const pl of planes) {
339
470
  pl.expectedExit = true;
@@ -342,7 +473,6 @@ export function createPlane({ root, env, audit }) {
342
473
  }
343
474
  // Deferred, so the reply and the broadcast flush before the sockets close.
344
475
  setTimeout(() => ctx.shutdown("session handoff"), 300);
345
- return true;
346
476
  };
347
477
 
348
478
  return {
@@ -360,7 +490,7 @@ export function createPlane({ root, env, audit }) {
360
490
  agentRefusalFor: (op) => ({
361
491
  session_list: "Agents cannot list the user's sessions.",
362
492
  r_versions: "Agents cannot list or choose R installations.",
363
- session_upgrade: "Agents cannot restart the user's session into another CarmaR.",
493
+ session_upgrade: "Agents cannot restart the user's session into another Beatrina.",
364
494
  session_restart: "Agents cannot restart the user's session.",
365
495
  "session-new": "Agents cannot start a session for the user.",
366
496
  })[op] || null,
@@ -379,15 +509,24 @@ export function createPlane({ root, env, audit }) {
379
509
  return reply({ error: "Only the local notebook page may ask this." });
380
510
  }
381
511
  if (HANDOFF_OPS.includes(cmd.type)) {
382
- return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true, keepTokenOk(cmd.resume) ? cmd.resume : null));
512
+ return handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true, keepTokenOk(cmd.resume) ? cmd.resume : null)
513
+ .then(reply, (e) => { handoff = null; reply({ ok: false, error: `The session restart failed: ${e.message}`, reason: "internal" }); });
383
514
  }
384
515
  if (cmd.type === "session_list") {
385
516
  const own = ctx.port();
386
517
  const recs = runtimeRecords(env);
387
518
  return Promise.all(recs.map(async (r) => {
388
519
  const isThis = r.port === own;
520
+ const h = isThis ? null : await healthOf(r.url);
521
+ // The fields the page reads (port, url, title, pid, alive, this) as they were; beside them,
522
+ // ADDITIVE, what the record says a session is (`app`, `document`, `documents`, `started`)
523
+ // and what its /health says it runs (`kernel_build`) — the same rows `beatrina status`
524
+ // and the menu describe a session by (docs/launch-menu-plan.md L1).
389
525
  return { port: r.port, url: String(r.url), title: r.title ?? "",
390
- pid: r.pid ?? null, alive: isThis ? true : await healthy(r.url), this: isThis };
526
+ pid: r.pid ?? null, alive: isThis ? true : h != null, this: isThis,
527
+ app: typeof r.app === "string" ? r.app : "", document: typeof r.document === "string" ? r.document : "",
528
+ documents: typeof r.documents === "string" ? r.documents : "", started: typeof r.started === "string" ? r.started : "",
529
+ kernel_build: isThis ? ctx.kernelBuild : (h && typeof h.kernel_build === "string" ? h.kernel_build : "") };
391
530
  })).then((sessions) => reply({ sessions }))
392
531
  .catch((e) => reply({ error: e.message }));
393
532
  }
@@ -405,7 +544,11 @@ export function createPlane({ root, env, audit }) {
405
544
  if (cmd.type === "session-new") {
406
545
  audit("session-new", { id: cmd.id });
407
546
  return startSibling(ctx).then((started) => {
408
- if (!started) return reply({ error: "Could not start another session." });
547
+ if (!started || started.error) {
548
+ audit("session-new-failed", { detail: started ? started.error : "no answer" });
549
+ return reply({ error: started ? started.error : "Could not start another session.", log: started ? started.log || "" : "" });
550
+ }
551
+ audit("session-new-started", { port: started.port, launch: started.launch });
409
552
  return reply({ port: started.port, url: started.url });
410
553
  }).catch((e) => {
411
554
  audit("session-new-failed", { detail: e.message });
@@ -425,8 +568,9 @@ export function createPlane({ root, env, audit }) {
425
568
  return true;
426
569
  }
427
570
  const restart = pathname === "/session/restart";
428
- const begun = handoffBegin(ctx, restart ? "restart" : "upgrade", restart && ctx.queryParam(req, "force") === "1");
429
- ctx.respond(res, begun.ok === true ? 200 : 409, "application/json", ctx.enc(begun));
571
+ handoffBegin(ctx, restart ? "restart" : "upgrade", restart && ctx.queryParam(req, "force") === "1")
572
+ .catch((e) => { handoff = null; return { ok: false, error: `The session restart failed: ${e.message}`, reason: "internal" }; })
573
+ .then((begun) => ctx.respond(res, begun.ok === true ? 200 : 409, "application/json", ctx.enc(begun)));
430
574
  return true;
431
575
  },
432
576
 
@@ -28,9 +28,9 @@ const AGENT_WHY = Object.freeze({
28
28
  // secret, but because handing back the confinement root, the audit-log path
29
29
  // and whether AI-text logging is on is a MAP OF THE DEPLOYMENT'S CONTROLS,
30
30
  // given to a process whose entire premise is that it is not the user.
31
- settings_get: "Agents cannot read CarmaR's settings.",
32
- settings_set: "Agents cannot change CarmaR's settings.",
33
- settings_reset: "Agents cannot reset CarmaR's settings.",
31
+ settings_get: "Agents cannot read Beatrina's settings.",
32
+ settings_set: "Agents cannot change Beatrina's settings.",
33
+ settings_reset: "Agents cannot reset Beatrina's settings.",
34
34
  });
35
35
 
36
36
  export function createPlane({ audit, settings } = {}) {
@@ -48,7 +48,7 @@ export function createPlane({ audit, settings } = {}) {
48
48
  }
49
49
  if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
50
50
  audit?.("settings-refused", { reason: "class", detail: cmd.type, class: rec.class || "unknown" });
51
- reply({ ok: false, reason: "class", error: "Only the notebook page may read or change CarmaR's settings." });
51
+ reply({ ok: false, reason: "class", error: "Only the notebook page may read or change Beatrina's settings." });
52
52
  return undefined;
53
53
  }
54
54
  // An unauthenticated shared kernel has no "the user" whose home directory
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // A port of `term_open`, `term_input`, `term_close`, `term_owned`,
4
4
  // `term_shell` and `pump_terms` in spike/serve.R, plus `term_resize`, which
5
- // carmapy added (`../carmar-python/docs/kernel-protocol.md` §3: "R only sizes
5
+ // carmapy added (`docs/python-kernel-records/kernel-protocol.md` §3: "R only sizes
6
6
  // at open"). Both are served here; a page that never resizes behaves exactly
7
7
  // as it does against serve.R.
8
8
  //
@@ -16,7 +16,7 @@
16
16
  // `exec` door the MCP plane refuses, wearing a prompt.
17
17
  //
18
18
  // What the shell can do is what the user can do; this grants nothing new and
19
- // is audited like every other door. `CARMAR_NO_TERMINAL=1` refuses it BY
19
+ // is audited like every other door. `BEATRINA_NO_TERMINAL=1` refuses it BY
20
20
  // NAME, with the reason, rather than dropping the frame — a dropped frame
21
21
  // reads as a hang, and lib/terminal.js already renders `event: "refused"`.
22
22
  //
@@ -30,6 +30,7 @@ import path from "node:path";
30
30
  import { createRequire } from "node:module";
31
31
  import { killProcessTree } from "./proc-tree.mjs";
32
32
  import { PIPE_NOTICE, pipeShell, pipeSpawn } from "./pipe-term.mjs";
33
+ import { envSetName, mirrorEnv } from "../env-names.mjs";
33
34
 
34
35
  const MAX_TERMS = 4;
35
36
  const TERM_INPUT_MAX = 65536;
@@ -127,7 +128,7 @@ export function createPlane({ env, audit }) {
127
128
 
128
129
  const enc = (o) => JSON.stringify(o);
129
130
  const send = (rec, id, fields) => { try { rec.ws.send(enc({ type: "term", id, ...fields })); } catch { /* the page is gone */ } };
130
- const turnedOff = () => env("CARMAR_NO_TERMINAL") === "1";
131
+ const turnedOff = () => env("BEATRINA_NO_TERMINAL") === "1";
131
132
 
132
133
  /** Only the page that opened a terminal may type into it, resize it or close it. */
133
134
  const owned = (rec, id) => {
@@ -161,8 +162,8 @@ export function createPlane({ env, audit }) {
161
162
  audit("term-refused", { detail: why });
162
163
  send(rec, "", { event: "refused", error: why });
163
164
  };
164
- if (turnedOff()) return refuse("The terminal is turned off on this kernel (CARMAR_NO_TERMINAL=1).");
165
- // serve.R refuses Windows outright ("CarmaR does not provide a pty on
165
+ if (turnedOff()) return refuse(`The terminal is turned off on this kernel (${envSetName(env, "NO_TERMINAL")}=1).`);
166
+ // serve.R refuses Windows outright ("Beatrina does not provide a pty on
166
167
  // Windows yet"), because processx has none there. node-pty ships ConPTY
167
168
  // prebuilds, so the refusal is no longer true by construction — the
168
169
  // platform is not asked, the loader is, and it says so if the prebuild
@@ -170,9 +171,9 @@ export function createPlane({ env, audit }) {
170
171
  const mine = [...terms.values()].filter((t) => t.rec === rec);
171
172
  if (mine.length >= MAX_TERMS) return refuse(`${mine.length} terminals are already open on this page.`);
172
173
  // node-pty first; with none (every installed R package: it is a native
173
- // module) the pipe terminal in pipe-term.mjs. CARMAR_TERMINAL_PTY=0 forces
174
+ // module) the pipe terminal in pipe-term.mjs. BEATRINA_TERMINAL_PTY=0 forces
174
175
  // the pipe, which is how a machine that HAS node-pty tests the other rung.
175
- if (!loaded) loaded = env("CARMAR_TERMINAL_PTY") === "0" ? { pty: null, error: "CARMAR_TERMINAL_PTY=0" } : loadPty(audit);
176
+ if (!loaded) loaded = env("BEATRINA_TERMINAL_PTY") === "0" ? { pty: null, error: `${envSetName(env, "TERMINAL_PTY")}=0` } : loadPty(audit);
176
177
 
177
178
  const cols = clamp(cmd.cols, 20, 400, 100);
178
179
  const rows = clamp(cmd.rows, 5, 200, 30);
@@ -193,11 +194,12 @@ export function createPlane({ env, audit }) {
193
194
  try {
194
195
  term = usePipe
195
196
  ? pipeSpawn(argv.file, argv.args, { cwd: real,
196
- env: { ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), CARMAR_PORT: String(ctxPort()) } })
197
+ env: mirrorEnv({ ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }) })
197
198
  : loaded.pty.spawn(argv.file, argv.args, {
198
199
  name: "xterm-256color", cols, rows, cwd: real,
199
- env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
200
- COLUMNS: String(cols), LINES: String(rows), CARMAR_PORT: String(ctxPort()) },
200
+ // The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
201
+ env: mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
202
+ COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }),
201
203
  });
202
204
  } catch (e) { return refuse(`the shell could not start: ${e.message}`); }
203
205
 
@@ -231,7 +233,7 @@ export function createPlane({ env, audit }) {
231
233
  // own words and a suite pins them (test/terminal.test.mjs).
232
234
  agentRefused: [],
233
235
  // serve.R advertises no terminal verb in ready.commands; neither does this.
234
- // A kernel with CARMAR_NO_TERMINAL=1 still answers the op — with a refusal
236
+ // A kernel with BEATRINA_NO_TERMINAL=1 still answers the op — with a refusal
235
237
  // that names the reason, which is what the pane renders.
236
238
  commands: [],
237
239
  capabilities: [],
@@ -8,7 +8,7 @@
8
8
  // THE PATH IS A COPY, NEVER THE SHIPPED FILE. The shipped copy lives inside the
9
9
  // app bundle or the installed package, which a Save must not write into (and on
10
10
  // an installed Mac cannot). So the first ask copies it to
11
- // `R_user_dir("carmar", "data")/test-files/` and every later ask returns that
11
+ // `R_user_dir("beatrina", "data")/test-files/` and every later ask returns that
12
12
  // copy UNTOUCHED — edits a person made to it are theirs, and a menu row that
13
13
  // silently reverted them would be the wrong kind of "always there". A copy that
14
14
  // was deleted comes back on the next click. `fresh: true` asks for a new copy
@@ -34,7 +34,7 @@ const OPS = ["test_file"];
34
34
  export function testFileCopy(kernelDir, { fresh = false } = {}) {
35
35
  const source = path.join(kernelDir, "examples", TEST_FILE_NAME);
36
36
  if (!fs.existsSync(source)) {
37
- return { ok: false, error: "This CarmaR does not carry its test file (examples/tna-complete-tutorial.Rmd is missing). Reinstall CarmaR." };
37
+ return { ok: false, error: "This Beatrina does not carry its test file (examples/tna-complete-tutorial.Rmd is missing). Reinstall Beatrina." };
38
38
  }
39
39
  const dir = path.join(ensureUserDir(appId(), "data"), "test-files");
40
40
  try { fs.mkdirSync(dir, { recursive: true }); } catch (e) { return { ok: false, error: `Could not create ${dir}: ${e.message}` }; }