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
@@ -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,9 @@ 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";
43
+ import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
40
44
 
41
45
  export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
42
46
  export const HANDOFF_OPS = Object.freeze(["session_upgrade", "session_restart"]);
@@ -45,28 +49,39 @@ export const SESSION_BAND = 201;
45
49
 
46
50
  const RUNTIME_NAME = /^kernel-([0-9]+)\.json$/;
47
51
 
48
- /** The runtime directory both supervisors write their records into. */
49
- export function runtimeDir(env) {
50
- 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;
51
60
  }
52
61
 
53
- /** 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
+ */
54
68
  export function runtimeRecords(env) {
55
- const dir = runtimeDir(env);
56
- let names = [];
57
- try { names = fs.readdirSync(dir); } catch { return []; }
58
- const out = [];
59
- for (const name of names) {
60
- const m = RUNTIME_NAME.exec(name);
61
- if (!m) continue;
62
- let rec = null;
63
- try { rec = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { rec = null; }
64
- if (!rec || typeof rec !== "object" || rec.port == null || rec.url == null) continue;
65
- const port = Number.parseInt(rec.port, 10);
66
- if (!Number.isInteger(port)) continue;
67
- 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
+ }
68
83
  }
69
- return out.sort((a, b) => a.port - b.port);
84
+ return [...byPort.values()].sort((a, b) => a.port - b.port);
70
85
  }
71
86
 
72
87
  /**
@@ -77,15 +92,22 @@ export function runtimeRecords(env) {
77
92
  * connected page for a full minute per port.
78
93
  */
79
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) {
80
100
  const target = `${String(url).replace(/\/+$/, "")}/health`;
81
101
  const ctrl = new AbortController();
82
102
  const timer = setTimeout(() => ctrl.abort(), timeoutMs);
83
103
  try {
84
104
  const res = await fetch(target, { signal: ctrl.signal, cache: "no-store" });
85
- if (!res.ok) return false;
86
- 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; }
87
109
  } catch {
88
- return false;
110
+ return null;
89
111
  } finally {
90
112
  clearTimeout(timer);
91
113
  }
@@ -180,10 +202,9 @@ export function createPlane({ root, env, audit }) {
180
202
  const siblingArgs = mainPath ? [mainPath] : [];
181
203
  let versionsCache = null;
182
204
 
183
- const sessionBase = (() => {
184
- const given = Number.parseInt(env("CARMAR_SESSION_BASE", "4747"), 10);
185
- return Number.isInteger(given) && given >= 1024 && given <= 65000 ? given : 4747;
186
- })();
205
+ const base = sessionBase(env);
206
+ /** Ports chosen by a sibling start that has not finished yet. */
207
+ const pendingPorts = new Set();
187
208
 
188
209
  /**
189
210
  * Start another supervisor on the next free adjacent port.
@@ -191,47 +212,73 @@ export function createPlane({ root, env, audit }) {
191
212
  * Adjacent and stable on purpose (browsers key storage to the origin), and
192
213
  * DETACHED — closing this session must not close that one. The port is a
193
214
  * PREFERENCE, not a promise: between the probe and the child's own bind the
194
- * port can go, so what is waited for is a NEW live port, whichever appears.
195
- * serve.R learned that too: waiting for the exact port reported failure for
196
- * 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.
197
228
  */
198
229
  const startSibling = async (ctx) => {
199
230
  const own = ctx.port();
200
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).
201
235
  let chosen = null;
202
- for (let p = sessionBase; p <= sessionBase + SESSION_BAND - 1; p += 1) {
203
- 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; }
204
238
  }
205
- if (chosen == null) return null;
206
- 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)}`;
207
247
  // The pipes go to a FILE, not to a pipe nobody reads: a detached sibling
208
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`);
209
250
  let logFd = "ignore";
210
- try {
211
- logFd = fs.openSync(path.join(os.tmpdir(), `beatrix-session-${process.pid}-${chosen}.log`), "a");
212
- } 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
+ };
213
255
  let child;
214
256
  try {
215
257
  child = spawn(process.execPath, siblingArgs, {
216
258
  cwd: root,
217
- 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" }),
218
260
  detached: true,
219
261
  stdio: ["ignore", logFd, logFd],
220
262
  });
221
263
  child.unref();
222
- } 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; });
223
271
  const deadline = Date.now() + 60000;
224
272
  while (Date.now() < deadline) {
225
- const fresh = (await liveSessionPorts(env, own)).filter((p) => !before.has(p));
226
- if (fresh.length) {
227
- const got = fresh[0];
228
- const rec = runtimeRecords(env).find((r) => r.port === got);
229
- 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() };
230
278
  }
231
- if (child.exitCode !== null || child.signalCode !== null) return null;
232
279
  await new Promise((r) => setTimeout(r, 250));
233
280
  }
234
- return null;
281
+ return { error: "The new session did not become ready within 60 seconds.", log: logTail() };
235
282
  };
236
283
 
237
284
  // ── the handoff ─────────────────────────────────────────────────────────
@@ -241,12 +288,12 @@ export function createPlane({ root, env, audit }) {
241
288
  const handoffRefusal = (ctx, mode, force) => {
242
289
  const refuse = (error, reason) => ({ error, reason });
243
290
  const installed = ctx.installedBuild();
244
- 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");
245
292
  if (mode === "upgrade" && installed === ctx.kernelBuild) {
246
- 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");
247
294
  }
248
295
  if (!ctx.notebookPage(installed)) {
249
- 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");
250
297
  }
251
298
  // An attempt that has spawned owns the port until this process exits.
252
299
  if (handoff) return refuse("A session restart is already under way.", "under-way");
@@ -268,7 +315,24 @@ export function createPlane({ root, env, audit }) {
268
315
  return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
269
316
  };
270
317
 
271
- const handoffBegin = (ctx, mode = "upgrade", force = false) => {
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) => {
272
336
  mode = mode === "restart" ? "restart" : "upgrade";
273
337
  force = force === true && mode === "restart";
274
338
  const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
@@ -281,55 +345,126 @@ export function createPlane({ root, env, audit }) {
281
345
  const cap = secureToken(32);
282
346
  const page = ctx.notebookFileUrl(ctx.notebookPage(installed), ctx.port(), cap);
283
347
  const started = ctx.startedSeconds;
284
- handoff = { to: installed, cap, page, mode, force };
348
+ handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
285
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
+ }
286
366
  // Every page learns where its successor's notebook is — over the gated
287
367
  // socket only; the capability never appears in /health. `started` is this
288
368
  // kernel's boot time: a page recognises its successor by a LATER one, the
289
369
  // only test that also works when the build does not change.
290
- const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force });
370
+ const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
291
371
  ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
292
- const spawned = handoffSpawn(ctx);
293
- if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
372
+ handoffRetire(ctx);
294
373
  return { ok: true, from: ctx.kernelBuild, to: installed, page, started, mode, force };
295
374
  };
296
375
 
297
- const handoffAbandon = (ctx, reason) => {
298
- audit("session-upgrade-failed", { reason });
299
- handoff = null;
300
- const told = ctx.enc({ type: "session-upgrade-failed", error: reason });
301
- 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;
302
386
  };
303
387
 
304
- /** Start the successor on this port, kill the worker tree, and leave. */
305
- 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) => {
306
393
  const h = handoff;
307
394
  const dir = stateDir();
308
395
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the log is a courtesy */ }
309
396
  const port = ctx.port();
310
397
  const record = ctx.runtimeRecord();
311
- const childEnv = { ...process.env,
312
- 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",
313
404
  // A successor that quietly moved to a free port would leave the page
314
405
  // polling a dead one; strict means it refuses and its log says why.
315
- CARMAR_PORT_STRICT: "1",
316
- CARMAR_FILE_LAUNCH_CAP: h.cap, CARMAR_HANDOFF_FROM: ctx.kernelBuild,
317
- CARMAR_SESSION_TITLE: record.title || "" };
318
- if (record.listen) childEnv.CARMAR_LISTEN = "1"; else delete childEnv.CARMAR_LISTEN;
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?.() || []),
411
+ // Only a token travels, never a path; the successor re-checks it.
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; }
319
418
  let out = "ignore";
320
- 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"; }
321
420
  let child = null;
322
421
  try {
323
422
  const { cmd, args } = successorCommand();
324
- 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 });
325
424
  child.unref();
425
+ child.on("error", () => { /* reported through exitCode / the deadline below */ });
326
426
  } catch { child = null; }
327
427
  if (typeof out === "number") { try { fs.closeSync(out); } catch { /* closed */ } }
328
- 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" };
329
429
  audit("session-upgrade-spawned", { to: h.to, pid: child.pid, mode: h.mode });
330
- // Spawn first, kill second: the worker's whole process GROUP goes down only
331
- // once the successor exists, so a failed spawn leaves the session untouched.
332
- // 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.
333
468
  const planes = ctx.plane.planes ? [...ctx.plane.planes.values()] : [ctx.plane];
334
469
  for (const pl of planes) {
335
470
  pl.expectedExit = true;
@@ -338,7 +473,6 @@ export function createPlane({ root, env, audit }) {
338
473
  }
339
474
  // Deferred, so the reply and the broadcast flush before the sockets close.
340
475
  setTimeout(() => ctx.shutdown("session handoff"), 300);
341
- return true;
342
476
  };
343
477
 
344
478
  return {
@@ -356,7 +490,7 @@ export function createPlane({ root, env, audit }) {
356
490
  agentRefusalFor: (op) => ({
357
491
  session_list: "Agents cannot list the user's sessions.",
358
492
  r_versions: "Agents cannot list or choose R installations.",
359
- 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.",
360
494
  session_restart: "Agents cannot restart the user's session.",
361
495
  "session-new": "Agents cannot start a session for the user.",
362
496
  })[op] || null,
@@ -375,15 +509,24 @@ export function createPlane({ root, env, audit }) {
375
509
  return reply({ error: "Only the local notebook page may ask this." });
376
510
  }
377
511
  if (HANDOFF_OPS.includes(cmd.type)) {
378
- return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true));
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" }); });
379
514
  }
380
515
  if (cmd.type === "session_list") {
381
516
  const own = ctx.port();
382
517
  const recs = runtimeRecords(env);
383
518
  return Promise.all(recs.map(async (r) => {
384
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).
385
525
  return { port: r.port, url: String(r.url), title: r.title ?? "",
386
- 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 : "") };
387
530
  })).then((sessions) => reply({ sessions }))
388
531
  .catch((e) => reply({ error: e.message }));
389
532
  }
@@ -401,7 +544,11 @@ export function createPlane({ root, env, audit }) {
401
544
  if (cmd.type === "session-new") {
402
545
  audit("session-new", { id: cmd.id });
403
546
  return startSibling(ctx).then((started) => {
404
- 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 });
405
552
  return reply({ port: started.port, url: started.url });
406
553
  }).catch((e) => {
407
554
  audit("session-new-failed", { detail: e.message });
@@ -421,8 +568,9 @@ export function createPlane({ root, env, audit }) {
421
568
  return true;
422
569
  }
423
570
  const restart = pathname === "/session/restart";
424
- const begun = handoffBegin(ctx, restart ? "restart" : "upgrade", restart && ctx.queryParam(req, "force") === "1");
425
- 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)));
426
574
  return true;
427
575
  },
428
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}` }; }