beatrina 0.8.7 → 0.9.83

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.83.html +1903 -0
  4. package/beatrina_V0.9.83.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 +29 -12
  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 +415 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/expect-term.mjs +102 -0
  57. package/host/planes/files.mjs +0 -0
  58. package/host/planes/jobs.mjs +43 -20
  59. package/host/planes/latex.mjs +15 -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 +226 -82
  66. package/host/planes/settings.mjs +4 -4
  67. package/host/planes/terminal.mjs +33 -17
  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 +75 -18
  74. package/host/session-keep.mjs +1 -1
  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 +34 -6
  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 +19855 -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 +18 -18
  95. package/kernel/knitr-run.R +58 -10
  96. package/kernel/latex.R +128 -30
  97. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +254 -63
  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 +542 -119
  104. package/kernel/workspace-keep.R +3 -3
  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.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,9 @@ 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 { expectSpawn } from "./expect-term.mjs";
34
+ import { whichSync } from "../which.mjs";
35
+ import { envSetName, mirrorEnv } from "../env-names.mjs";
33
36
 
34
37
  const MAX_TERMS = 4;
35
38
  const TERM_INPUT_MAX = 65536;
@@ -122,12 +125,13 @@ export function createPlane({ env, audit }) {
122
125
  const terms = new Map();
123
126
  let seq = 0;
124
127
  let loaded = null; // lazy: a kernel that never opens one pays nothing
128
+ let expectBin; // undefined: not yet looked for; "" when there is none
125
129
  let ctxRef = null;
126
130
  const ctxPort = () => (ctxRef ? ctxRef.port() : 0);
127
131
 
128
132
  const enc = (o) => JSON.stringify(o);
129
133
  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";
134
+ const turnedOff = () => env("BEATRINA_NO_TERMINAL") === "1";
131
135
 
132
136
  /** Only the page that opened a terminal may type into it, resize it or close it. */
133
137
  const owned = (rec, id) => {
@@ -161,8 +165,8 @@ export function createPlane({ env, audit }) {
161
165
  audit("term-refused", { detail: why });
162
166
  send(rec, "", { event: "refused", error: why });
163
167
  };
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
168
+ if (turnedOff()) return refuse(`The terminal is turned off on this kernel (${envSetName(env, "NO_TERMINAL")}=1).`);
169
+ // serve.R refuses Windows outright ("Beatrina does not provide a pty on
166
170
  // Windows yet"), because processx has none there. node-pty ships ConPTY
167
171
  // prebuilds, so the refusal is no longer true by construction — the
168
172
  // platform is not asked, the loader is, and it says so if the prebuild
@@ -170,9 +174,9 @@ export function createPlane({ env, audit }) {
170
174
  const mine = [...terms.values()].filter((t) => t.rec === rec);
171
175
  if (mine.length >= MAX_TERMS) return refuse(`${mine.length} terminals are already open on this page.`);
172
176
  // 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
177
+ // module) the pipe terminal in pipe-term.mjs. BEATRINA_TERMINAL_PTY=0 forces
174
178
  // 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);
179
+ if (!loaded) loaded = env("BEATRINA_TERMINAL_PTY") === "0" ? { pty: null, error: `${envSetName(env, "TERMINAL_PTY")}=0` } : loadPty(audit);
176
180
 
177
181
  const cols = clamp(cmd.cols, 20, 400, 100);
178
182
  const rows = clamp(cmd.rows, 5, 200, 30);
@@ -180,7 +184,17 @@ export function createPlane({ env, audit }) {
180
184
  if (scalarChr(cmd.cwd) && cmd.cwd) {
181
185
  try { if (fs.statSync(cmd.cwd).isDirectory()) wd = cmd.cwd; } catch { /* keep the kernel's cwd */ }
182
186
  }
183
- const usePipe = !loaded.pty;
187
+ // THREE rungs: node-pty; else `expect`, which ships with macOS and runs a
188
+ // program on a real pty over plain pipes (host/planes/expect-term.mjs) —
189
+ // the packaged app, which can load no native module, gets a real terminal
190
+ // from it (FINDINGS 2026-09-22 B8); else the pipe. BEATRINA_TERMINAL_EXPECT=0
191
+ // skips the middle rung, which is how a machine with expect tests the pipe.
192
+ if (expectBin === undefined) {
193
+ expectBin = loaded.pty || process.platform === "win32" || env("BEATRINA_TERMINAL_EXPECT") === "0"
194
+ ? "" : whichSync("expect");
195
+ }
196
+ const useExpect = !loaded.pty && !!expectBin;
197
+ const usePipe = !loaded.pty && !useExpect;
184
198
  const sh = usePipe ? pipeShell(process.env) : termShell(process.env);
185
199
  const argv = usePipe ? { file: sh.cmd, args: sh.args } : termArgv(sh);
186
200
  // The shell runs in the PHYSICAL directory (processx resolves the wd for
@@ -190,15 +204,16 @@ export function createPlane({ env, audit }) {
190
204
  let real = wd;
191
205
  try { real = fs.realpathSync(wd); } catch { real = wd; }
192
206
  let term;
207
+ const ptyEnv = () => mirrorEnv({ ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor",
208
+ COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) });
193
209
  try {
194
- term = usePipe
210
+ term = useExpect
211
+ ? expectSpawn(expectBin, argv.file, argv.args, { cols, rows, cwd: real, env: ptyEnv() })
212
+ : usePipe
195
213
  ? pipeSpawn(argv.file, argv.args, { cwd: real,
196
- env: { ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), CARMAR_PORT: String(ctxPort()) } })
197
- : loaded.pty.spawn(argv.file, argv.args, {
198
- 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()) },
201
- });
214
+ env: mirrorEnv({ ...process.env, TERM: "dumb", COLUMNS: String(cols), LINES: String(rows), BEATRINA_PORT: String(ctxPort()) }) })
215
+ // The shell sees BEATRINA_PORT and, mirrored, its older spelling (a script written before the rename).
216
+ : loaded.pty.spawn(argv.file, argv.args, { name: "xterm-256color", cols, rows, cwd: real, env: ptyEnv() });
202
217
  } catch (e) { return refuse(`the shell could not start: ${e.message}`); }
203
218
 
204
219
  seq += 1;
@@ -216,7 +231,8 @@ export function createPlane({ env, audit }) {
216
231
  audit("term-exit", { id, detail: String(exitCode) });
217
232
  send(rec, id, { event: "exit", code: exitCode ?? null, reason: "exited" });
218
233
  });
219
- audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe });
234
+ audit("term-open", { id, detail: sh.cmd, class: rec.class ?? "unknown", pty: !usePipe,
235
+ via: loaded.pty ? "node-pty" : useExpect ? "expect" : "pipe" });
220
236
  send(rec, id, { event: "open", shell: sh.cmd, pid: term.pid, cols, rows, cwd: wd, pty: !usePipe });
221
237
  if (usePipe) {
222
238
  if (loaded.error) audit("term-pipe", { id, detail: loaded.error });
@@ -231,7 +247,7 @@ export function createPlane({ env, audit }) {
231
247
  // own words and a suite pins them (test/terminal.test.mjs).
232
248
  agentRefused: [],
233
249
  // 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
250
+ // A kernel with BEATRINA_NO_TERMINAL=1 still answers the op — with a refusal
235
251
  // that names the reason, which is what the pane renders.
236
252
  commands: [],
237
253
  capabilities: [],