beatrina 0.8.7 → 0.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/NOTICES +1 -1
  2. package/README.md +6 -0
  3. package/beatrina_V0.9.43.html +1885 -0
  4. package/beatrina_V0.9.43.html.inputs.json +1 -0
  5. package/bin/beatrina.mjs +152 -24
  6. package/bin/browser.mjs +31 -0
  7. package/bin/cli.mjs +64 -5
  8. package/bin/doctor-rows.mjs +31 -0
  9. package/bin/failsafe.mjs +8 -3
  10. package/bin/finder.mjs +74 -0
  11. package/bin/lsquery.swift +55 -0
  12. package/bin/pages.mjs +81 -0
  13. package/bin/python-setup.mjs +207 -0
  14. package/bin/runtime-dirs.mjs +27 -0
  15. package/bin/sessions.mjs +30 -10
  16. package/bin/shortcut.mjs +250 -42
  17. package/bin/update-check.mjs +2 -2
  18. package/build-info.json +1 -1
  19. package/engines/js/worker.mjs +6 -3
  20. package/engines/python/adapter.py +14 -13
  21. package/engines/python/analyze.py +4 -2
  22. package/engines/python/bootstrap.py +32 -31
  23. package/engines/python/debugger.py +10 -6
  24. package/engines/python/engine.json +1 -1
  25. package/engines/python/handoff.py +4 -2
  26. package/engines/python/worker.py +39 -10
  27. package/engines/r/engine.json +1 -1
  28. package/engines/r/handoff.R +4 -3
  29. package/failsafe/ai-policy.R +16 -16
  30. package/failsafe/ai-store.R +6 -6
  31. package/failsafe/cite.R +11 -11
  32. package/failsafe/journal.R +11 -11
  33. package/failsafe/plugins.R +27 -27
  34. package/failsafe/serve.R +241 -241
  35. package/host/ai-authority.mjs +55 -0
  36. package/host/ai-policy.mjs +48 -26
  37. package/host/bundle.mjs +194 -0
  38. package/host/deployment.mjs +25 -18
  39. package/host/engine-js.mjs +12 -17
  40. package/host/engine-pool.mjs +48 -6
  41. package/host/engine-python.mjs +80 -31
  42. package/host/engine-r.mjs +20 -21
  43. package/host/engine-stdio.mjs +35 -10
  44. package/host/env-names.mjs +148 -0
  45. package/host/gateway-token.mjs +51 -0
  46. package/host/journal-store.mjs +10 -9
  47. package/host/main.mjs +193 -77
  48. package/host/payload.mjs +131 -0
  49. package/host/planes/README.md +1 -1
  50. package/host/planes/ai-store.mjs +6 -5
  51. package/host/planes/ai.mjs +49 -29
  52. package/host/planes/analyze.mjs +6 -5
  53. package/host/planes/bundle.mjs +344 -0
  54. package/host/planes/choose.mjs +404 -0
  55. package/host/planes/cite.mjs +18 -17
  56. package/host/planes/files.mjs +0 -0
  57. package/host/planes/jobs.mjs +43 -20
  58. package/host/planes/latex.mjs +15 -5
  59. package/host/planes/mcp.mjs +80 -91
  60. package/host/planes/pair.mjs +24 -24
  61. package/host/planes/pipe-term.mjs +2 -2
  62. package/host/planes/plugins.mjs +1 -1
  63. package/host/planes/recent-documents.mjs +170 -0
  64. package/host/planes/sessions.mjs +226 -82
  65. package/host/planes/settings.mjs +4 -4
  66. package/host/planes/terminal.mjs +13 -11
  67. package/host/planes/test-file.mjs +2 -2
  68. package/host/planes/update.mjs +6 -13
  69. package/host/plugin-store.mjs +35 -52
  70. package/host/recent-documents.mjs +124 -0
  71. package/host/runtime-dir.mjs +47 -0
  72. package/host/server.mjs +75 -18
  73. package/host/session-keep.mjs +1 -1
  74. package/host/settings.mjs +85 -37
  75. package/host/update-record.mjs +3 -2
  76. package/host/user-dirs.mjs +60 -18
  77. package/host/which.mjs +39 -0
  78. package/host/windows-runtime.mjs +6 -3
  79. package/host/worker-plane.mjs +34 -6
  80. package/host/ws.mjs +9 -2
  81. package/host/zip.mjs +237 -0
  82. package/kernel/analyze.R +1 -1
  83. package/{check → kernel/check}/acceptance.mjs +1 -1
  84. package/kernel/check/knit-file.mjs +19639 -0
  85. package/{check → kernel/check}/session.mjs +49 -8
  86. package/kernel/deployment.R +20 -20
  87. package/kernel/examples/NOTICE.md +1 -1
  88. package/kernel/fileio.R +6 -6
  89. package/kernel/index.html +2 -2
  90. package/kernel/job-run.R +83 -22
  91. package/kernel/jobs.R +28 -12
  92. package/kernel/kernel-version +1 -1
  93. package/kernel/kernel.R +18 -18
  94. package/kernel/knitr-run.R +50 -9
  95. package/kernel/latex.R +128 -30
  96. package/kernel/mcp/{carmar-mcp.mjs → beatrina-mcp.mjs} +249 -59
  97. package/kernel/notebook-page.R +7 -7
  98. package/kernel/project.R +10 -10
  99. package/kernel/settings.R +105 -56
  100. package/kernel/sniff.R +4 -4
  101. package/kernel/typst.R +121 -0
  102. package/kernel/worker.R +522 -119
  103. package/kernel/workspace-keep.R +3 -3
  104. package/lib/agent-authoring-contract.js +28 -19
  105. package/lib/cell-kinds.js +3 -3
  106. package/lib/engine-labels.js +4 -4
  107. package/menu/Beatrina Menu.app/Contents/Info.plist +14 -0
  108. package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
  109. package/menu/Beatrina Menu.app/Contents/_CodeSignature/CodeResources +115 -0
  110. package/package.json +4 -3
  111. package/carmar_V0.8.7.html +0 -1522
package/bin/shortcut.mjs CHANGED
@@ -20,8 +20,11 @@ import { spawnSync } from "node:child_process";
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
+ import { listSessions } from "./sessions.mjs";
24
+ import { defaultFor, lsQuery, registeredPaths } from "./finder.mjs";
23
25
 
24
26
  export const BUNDLE_ID = "me.saqr.beatrina";
27
+ // beatrina-names: keep-start — the other product's identities, reclaimed from, are named here on purpose.
25
28
  // Each type is EXPORTED by Beatrina.app as its own UTI (UTExportedTypeDeclarations) and named in its
26
29
  // document types (LSItemContentTypes): Launch Services sets a default handler per UTI, and an app that
27
30
  // only lists an extension has no UTI of its own to be the default FOR — the first --default answered
@@ -36,12 +39,23 @@ export const DOC_TYPES = Object.freeze([
36
39
  { ext: "qmd", name: "Quarto document", mime: "text/x-quarto-markdown", uti: "me.saqr.beatrina.qmd", legacy: "me.saqr.carmar.qmd" },
37
40
  { ext: "Rmd", name: "R Markdown document", mime: "text/x-r-markdown", uti: "me.saqr.beatrina.rmd", legacy: "me.saqr.carmar.rmd" },
38
41
  { ext: "carmd", name: "Beatrina notebook document", mime: "text/markdown", uti: "me.saqr.beatrina.carmd", legacy: "me.saqr.carmar.carmd" },
42
+ // The .btrn is a ZIP (host/bundle.mjs): it conforms to zip-archive, not text, and its MIME type is
43
+ // its own — declaring application/zip would make Beatrina a Linux handler for every zip on the disk.
44
+ { ext: "btrn", name: "Beatrina document", mime: "application/x-beatrina-bundle", uti: "me.saqr.beatrina.btrn", conforms: "public.zip-archive" },
39
45
  ]);
46
+ // beatrina-names: keep-end
40
47
 
41
48
  const q = (s) => `'${String(s).replace(/'/g, "'\\''")}'`; // POSIX single-quote
42
49
  const asString = (s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
43
50
 
44
- /** The AppleScript applet's source. `launch` is the shell command prefix. */
51
+ /** The URL scheme the applet registers: `beatrina://start` from the page's Start R (lib/attach-menu.js startHint). */
52
+ export const URL_SCHEME = "beatrina";
53
+
54
+ /**
55
+ * The AppleScript applet's source. `launch` is the shell command prefix. `open location` is the
56
+ * page's Start R (beatrina://start, docs/launch-menu-plan.md L6): another session, whatever runs —
57
+ * the page asked because it found none — through the same background start the icon makes.
58
+ */
45
59
  export function appleScriptSource(launch) {
46
60
  return `on run
47
61
  \tdo shell script ${asString(`${launch} --from-app > /dev/null 2>&1 &`)}
@@ -52,23 +66,36 @@ on open theItems
52
66
  \t\tdo shell script ${asString(launch)} & " --from-app " & quoted form of (POSIX path of anItem) & " > /dev/null 2>&1 &"
53
67
  \tend repeat
54
68
  end open
69
+
70
+ on open location theURL
71
+ \tdo shell script ${asString(`${launch} --from-app --new > /dev/null 2>&1 &`)}
72
+ end open location
55
73
  `;
56
74
  }
57
75
 
76
+ /** The URL type the applet's Info.plist declares (CFBundleURLTypes), as plutil-insertable XML. */
77
+ export function urlTypesXml() {
78
+ return `<array><dict><key>CFBundleURLName</key><string>${BUNDLE_ID}.start</string>`
79
+ + `<key>CFBundleURLSchemes</key><array><string>${URL_SCHEME}</string></array></dict></array>`;
80
+ }
81
+
82
+ /** A type's extension spellings, once each (`Rmd` and `rmd`; `qmd` alone — not `qmd, qmd`). */
83
+ const extensionsXml = (t) => `<array>${[...new Set([t.ext, t.ext.toLowerCase()])].map((e) => `<string>${e}</string>`).join("")}</array>`;
84
+
58
85
  /** The document types an Info.plist declares, as plutil-insertable XML. */
59
86
  export function documentTypesXml() {
60
87
  return `<array>${DOC_TYPES.map((t) => `<dict><key>CFBundleTypeName</key><string>${t.name}</string>`
61
88
  + `<key>CFBundleTypeRole</key><string>Editor</string><key>LSHandlerRank</key><string>Alternate</string>`
62
- + `<key>LSItemContentTypes</key><array><string>${t.uti}</string><string>${t.legacy}</string></array>`
63
- + `<key>CFBundleTypeExtensions</key><array><string>${t.ext}</string><string>${t.ext.toLowerCase()}</string></array></dict>`).join("")}</array>`;
89
+ + `<key>LSItemContentTypes</key><array>${[t.uti, t.legacy].filter(Boolean).map((u) => `<string>${u}</string>`).join("")}</array>`
90
+ + `<key>CFBundleTypeExtensions</key>${extensionsXml(t)}</dict>`).join("")}</array>`;
64
91
  }
65
92
 
66
93
  /** The UTIs Beatrina.app exports for those types (UTExportedTypeDeclarations), as plutil-insertable XML. */
67
94
  export function exportedTypesXml() {
68
95
  return `<array>${DOC_TYPES.map((t) => `<dict><key>UTTypeIdentifier</key><string>${t.uti}</string>`
69
96
  + `<key>UTTypeDescription</key><string>${t.name}</string>`
70
- + `<key>UTTypeConformsTo</key><array><string>public.plain-text</string></array>`
71
- + `<key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key><array><string>${t.ext}</string><string>${t.ext.toLowerCase()}</string></array>`
97
+ + `<key>UTTypeConformsTo</key><array><string>${t.conforms || "public.plain-text"}</string></array>`
98
+ + `<key>UTTypeTagSpecification</key><dict><key>public.filename-extension</key>${extensionsXml(t)}`
72
99
  + `<key>public.mime-type</key><array><string>${t.mime}</string></array></dict></dict>`).join("")}</array>`;
73
100
  }
74
101
 
@@ -85,34 +112,103 @@ function run(cmd, args, opts = {}) {
85
112
 
86
113
  const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
87
114
 
88
- function macShortcut({ cli, dir, port, makeDefault, noOpen }) {
115
+ /**
116
+ * Take one registered copy of the app out of Launch Services. A path that no longer exists (a
117
+ * folder renamed or deleted under a registration) is made to resolve for the moment it takes — a
118
+ * symlink to `resolveTo`, the parents it needs — unregistered, and the symlink and those parents
119
+ * removed again; that is the one technique that made LS drop a dead path (LEARNINGS 2026-09-16).
120
+ * @returns {{ok: boolean, gone: boolean, detail?: string}}
121
+ */
122
+ export function unregisterCopy(stale, resolveTo, { lsregister = LSREGISTER } = {}) {
123
+ const gone = !fs.existsSync(stale);
124
+ const made = [];
125
+ try {
126
+ if (gone) {
127
+ let parent = path.dirname(stale);
128
+ const missing = [];
129
+ while (!fs.existsSync(parent)) { missing.unshift(parent); parent = path.dirname(parent); }
130
+ for (const d of missing) { fs.mkdirSync(d); made.push(d); }
131
+ fs.symlinkSync(resolveTo, stale);
132
+ }
133
+ run(lsregister, ["-u", stale]);
134
+ return { ok: true, gone };
135
+ } catch (e) {
136
+ return { ok: false, gone, detail: e.message };
137
+ } finally {
138
+ if (gone) {
139
+ try { fs.unlinkSync(stale); } catch { /* never made */ }
140
+ for (const d of made.reverse()) { try { fs.rmdirSync(d); } catch { /* not empty: leave it */ } }
141
+ }
142
+ }
143
+ }
144
+
145
+ /** Point every UTI a type can carry at `bundleId` (the JXA bridge is the only writer; 0 is success). */
146
+ function setDefaultHandler(types, bundleId) {
147
+ // Beatrina's own UTI for each type, CarmaR's legacy one, and whatever UTI Launch Services currently (beatrina-names: keep)
148
+ // resolves the extension to (another app's declaration, or a dynamic one) — all pointed at Beatrina,
149
+ // so a .qmd opens here whichever identity the system had attached to it.
150
+ const js = `ObjC.import("CoreServices");
151
+ const types = ${JSON.stringify(types.map((t) => ({ ext: t.ext, uti: t.uti, legacy: t.legacy })))};
152
+ types.map((t) => { const tagged = $.UTTypeCreatePreferredIdentifierForTag($.kUTTagClassFilenameExtension, $(t.ext), $()).js;
153
+ const utis = [...new Set([t.uti, t.legacy, tagged])].filter(Boolean);
154
+ return t.ext + "=" + utis.map((u) => $.LSSetDefaultRoleHandlerForContentType($(u), $.kLSRolesAll, $(${JSON.stringify(bundleId)}))).join("/"); }).join(" ");`;
155
+ const out = run("osascript", ["-l", "JavaScript", "-e", js]).trim();
156
+ return { out, failed: /=(?:[^0 /]|-\d)/.test(out) };
157
+ }
158
+
159
+ function macShortcut({ cli, dir, port, makeDefault, noOpen, bundleId }) {
89
160
  const app = path.join(dir || path.join(os.homedir(), "Applications"), "Beatrina.app");
161
+ const exts = DOC_TYPES.map((t) => t.ext);
162
+ // Before anything changes: what Finder does today (finder.mjs, read-only) — which extensions
163
+ // Beatrina is already the default for, so a rebuild puts that choice back rather than losing it,
164
+ // and which copies are registered under this id, so every other one can be taken out below.
165
+ const before = lsQuery({ exts, bundleIds: [bundleId] });
166
+ const wasDefault = DOC_TYPES.filter((t) => defaultFor(before, bundleId, [t.ext]).length);
90
167
  fs.mkdirSync(path.dirname(app), { recursive: true });
91
168
  fs.rmSync(app, { recursive: true, force: true });
92
169
  const src = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "beatrina-app-")), "Beatrina.applescript");
93
170
  fs.writeFileSync(src, appleScriptSource(launchCommand({ cli, port, noOpen })));
94
171
  run("osacompile", ["-o", app, src]);
95
172
  const plist = path.join(app, "Contents", "Info.plist");
96
- run("plutil", ["-replace", "CFBundleIdentifier", "-string", BUNDLE_ID, plist]);
173
+ run("plutil", ["-replace", "CFBundleIdentifier", "-string", bundleId, plist]);
97
174
  run("plutil", ["-replace", "CFBundleName", "-string", "Beatrina", plist]);
98
175
  run("plutil", ["-replace", "CFBundleDocumentTypes", "-xml", documentTypesXml(), plist]);
99
176
  run("plutil", ["-replace", "UTExportedTypeDeclarations", "-xml", exportedTypesXml(), plist]);
177
+ run("plutil", ["-replace", "CFBundleURLTypes", "-xml", urlTypesXml(), plist]);
100
178
  run("codesign", ["--force", "--sign", "-", app]); // the plist changed after osacompile signed it
101
- if (fs.existsSync(LSREGISTER)) run(LSREGISTER, ["-f", app]);
102
- const said = [`Beatrina.app is in ${path.dirname(app)}. Double-click it, or right-click a .qmd and choose Open With ▸ Beatrina.`];
103
- if (makeDefault) {
104
- // Beatrina's own UTI for each type, and whatever UTI Launch Services currently resolves the
105
- // extension to (another app's declaration, or a dynamic one) — both are pointed at Beatrina, so
106
- // a .qmd opens here whichever identity the system had attached to it. 0 is success.
107
- const js = `ObjC.import("CoreServices");
108
- const types = ${JSON.stringify(DOC_TYPES.map((t) => ({ ext: t.ext, uti: t.uti, legacy: t.legacy })))};
109
- types.map((t) => { const tagged = $.UTTypeCreatePreferredIdentifierForTag($.kUTTagClassFilenameExtension, $(t.ext), $()).js;
110
- const utis = [...new Set([t.uti, t.legacy, tagged])];
111
- return t.ext + "=" + utis.map((u) => $.LSSetDefaultRoleHandlerForContentType($(u), $.kLSRolesAll, $(${JSON.stringify(BUNDLE_ID)}))).join("/"); }).join(" ");`;
112
- const out = run("osascript", ["-l", "JavaScript", "-e", js]).trim();
113
- const failed = /=(?:[^0 /]|-\d)/.test(out);
114
- said.push(failed ? `Beatrina could not be made the default for every type (${out}; 0 means done).`
115
- : `Beatrina is now the default app for ${DOC_TYPES.map((t) => `.${t.ext}`).join(", ")}.`);
179
+ const said = [];
180
+ // Idempotent: every OTHER copy registered under this id is unregistered before this one is
181
+ // registered. Launch Services picks between copies at its own discretion — an older Beatrina.app
182
+ // dragged somewhere, a scratch build a killed test left behind, a folder that no longer exists —
183
+ // so which copy a double-click reaches is its choice until there is one.
184
+ if (fs.existsSync(LSREGISTER)) {
185
+ for (const stale of registeredPaths(before, bundleId).filter((p) => path.resolve(p) !== path.resolve(app))) {
186
+ const done = unregisterCopy(stale, app);
187
+ said.push(done.ok ? `Unregistered another copy of Beatrina.app at ${stale}${done.gone ? " (a folder that no longer exists)" : ""}.`
188
+ : `Could not unregister the copy of Beatrina.app at ${stale} (${done.detail}); Finder may still pick it.`);
189
+ }
190
+ run(LSREGISTER, ["-f", app]);
191
+ }
192
+ said.push(`Beatrina.app is in ${path.dirname(app)}. Double-click it, or right-click a .qmd and choose Open With ▸ Beatrina.`);
193
+ // The default: every type with --default; otherwise only the types Beatrina was already the
194
+ // default for (a rebuild is not a choice — "never by surprise"), re-asserted because a fresh
195
+ // registration can lose it.
196
+ const wanted = makeDefault ? DOC_TYPES : wasDefault;
197
+ if (wanted.length) {
198
+ const set = setDefaultHandler(wanted, bundleId);
199
+ if (set.failed) said.push(`Beatrina could not be made the default for every type (${set.out}; 0 means done).`);
200
+ else if (!makeDefault) said.push(`Beatrina stays the default app for ${wasDefault.map((t) => `.${t.ext}`).join(", ")}, as before.`);
201
+ if (makeDefault && !set.failed) {
202
+ // Read back from Launch Services, never inferred from return codes.
203
+ const after = lsQuery({ exts, bundleIds: [bundleId] });
204
+ if (!after) said.push(`Beatrina is now the default app for ${exts.map((e) => `.${e}`).join(", ")} (not read back: no Swift toolchain).`);
205
+ else {
206
+ const now = defaultFor(after, bundleId, exts);
207
+ const still = exts.filter((e) => !now.includes(e)).map((e) => { const r = after.ext.find((x) => x.ext === e); return `.${e} (${(r && (r.name || r.handler)) || "no app"})`; });
208
+ if (now.length) said.push(`Finder now opens ${now.map((e) => `.${e}`).join(", ")} with Beatrina.`);
209
+ if (still.length) said.push(`Finder still opens ${still.join(", ")} elsewhere — run beatrina doctor, then beatrina shortcut --default again.`);
210
+ }
211
+ }
116
212
  }
117
213
  return { app, said };
118
214
  }
@@ -155,25 +251,137 @@ function linuxShortcut({ cli, port, makeDefault }) {
155
251
  return { app: desktop, said };
156
252
  }
157
253
 
158
- /** Make the icon (and, with makeDefault, the default .qmd app). */
159
- export function makeShortcut({ cli, dir = "", port = "", makeDefault = false, noOpen = false, platform = process.platform }) {
160
- if (platform === "darwin") return macShortcut({ cli, dir, port, makeDefault, noOpen });
161
- if (platform === "win32") return windowsShortcut({ cli, port, makeDefault });
162
- return linuxShortcut({ cli, port, makeDefault });
163
- }
164
-
165
- /** A running session of this app (runtime records + a live /health), or null. */
166
- export async function liveSession({ app = "beatrina", runtimeDir = process.env.CARMAR_RUNTIME_DIR || path.join(os.homedir(), ".carmar", "run"), port = "" } = {}) {
167
- let names = [];
168
- try { names = fs.readdirSync(runtimeDir).filter((n) => /^kernel-\d+\.json$/.test(n)); } catch { return null; }
169
- for (const name of names) {
170
- let rec;
171
- try { rec = JSON.parse(fs.readFileSync(path.join(runtimeDir, name), "utf8")); } catch { continue; }
172
- if (rec.app !== app || (port && String(rec.port) !== String(port))) continue;
173
- try {
174
- const res = await fetch(`http://127.0.0.1:${rec.port}/health`, { signal: AbortSignal.timeout(1500) });
175
- if (res.ok) return rec;
176
- } catch { /* a record the session left behind */ }
254
+ // ── the menu (docs/launch-menu-plan.md M2) ──────────────────────────────────
255
+ // `Beatrina Menu.app` ships in the npm package at menu/ (tools/app/build-helper.sh --product
256
+ // beatrina; a binary and an Info.plist, nothing of its own inside). `beatrina shortcut --menu`
257
+ // copies it beside Beatrina.app, writes the exact command line the applet bakes to
258
+ // Contents/Resources/beatrina-command (an absolute Node and CLI path: a menu-bar process has no
259
+ // PATH), registers it and opens it; --login adds a LaunchAgent with RunAtLoad and NO KeepAlive (a
260
+ // menu, not a daemon — helper-agent.sh's "additive and explicit" rule); --no-menu removes both.
261
+ // Explicit both ways: `beatrina shortcut` alone installs no menu, and only refreshes the command
262
+ // line of a menu already installed beside the applet, so the two stay in step after a Node upgrade.
263
+ export const MENU_BUNDLE_ID = "me.saqr.beatrina.menu";
264
+ export const MENU_NAME = "Beatrina Menu.app";
265
+ const menuLabel = () => process.env.BEATRINA_MENU_LABEL || MENU_BUNDLE_ID;
266
+ const menuAgentPlist = () => path.join(os.homedir(), "Library", "LaunchAgents", `${menuLabel()}.plist`);
267
+ const launchDomain = () => `gui/${process.getuid()}`;
268
+
269
+ /** The LaunchAgent that starts the menu at login: RunAtLoad, and deliberately no KeepAlive. */
270
+ export function menuAgentXml({ label, binary }) {
271
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;");
272
+ return `<?xml version="1.0" encoding="UTF-8"?>
273
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
274
+ <plist version="1.0"><dict>
275
+ <key>Label</key><string>${esc(label)}</string>
276
+ <key>ProgramArguments</key><array><string>${esc(binary)}</string></array>
277
+ <key>RunAtLoad</key><true/>
278
+ <key>ProcessType</key><string>Interactive</string>
279
+ </dict></plist>
280
+ `;
281
+ }
282
+
283
+ /** Write (or refresh) the command line the menu runs, and re-seal the bundle around it. */
284
+ function writeMenuCommand(menuApp, cli) {
285
+ const resources = path.join(menuApp, "Contents", "Resources");
286
+ fs.mkdirSync(resources, { recursive: true });
287
+ fs.writeFileSync(path.join(resources, "beatrina-command"), `${launchCommand({ cli })}\n`, { mode: 0o644 });
288
+ run("codesign", ["--force", "--deep", "--sign", "-", menuApp]); // the resources changed after the build signed it
289
+ }
290
+
291
+ /** Install the menu beside the applet. @returns {string[]} what was done */
292
+ function installMenu({ cli, dir, login, noOpen, menuSource }) {
293
+ const said = [];
294
+ if (!menuSource || !fs.existsSync(path.join(menuSource, "Contents", "MacOS", "Beatrina Menu"))) {
295
+ return [`This Beatrina carries no menu (${menuSource || "no menu/ folder"}); a package built on a Mac ships one. Nothing was installed.`];
177
296
  }
178
- return null;
297
+ const menuApp = path.join(dir || path.join(os.homedir(), "Applications"), MENU_NAME);
298
+ // Quit a running menu before its files change under it; a menu that is not running is not an error.
299
+ spawnSync("osascript", ["-e", `tell application id "${MENU_BUNDLE_ID}" to quit`], { timeout: 10000 });
300
+ fs.rmSync(menuApp, { recursive: true, force: true });
301
+ fs.cpSync(menuSource, menuApp, { recursive: true });
302
+ writeMenuCommand(menuApp, cli);
303
+ if (fs.existsSync(LSREGISTER)) run(LSREGISTER, ["-f", menuApp]);
304
+ said.push(`${MENU_NAME} is in ${path.dirname(menuApp)}; it runs \`beatrina\` for you and starts nothing itself.`);
305
+ if (login) {
306
+ const plist = menuAgentPlist();
307
+ fs.mkdirSync(path.dirname(plist), { recursive: true });
308
+ fs.writeFileSync(plist, menuAgentXml({ label: menuLabel(), binary: path.join(menuApp, "Contents", "MacOS", "Beatrina Menu") }), { mode: 0o644 });
309
+ spawnSync("launchctl", ["bootout", `${launchDomain()}/${menuLabel()}`], { timeout: 10000 });
310
+ if (noOpen) said.push(`It will start at your next login (${plist}).`);
311
+ else {
312
+ const up = spawnSync("launchctl", ["bootstrap", launchDomain(), plist], { encoding: "utf8", timeout: 20000 });
313
+ said.push(up.status === 0 ? `It is running now and starts at every login (${plist}); beatrina shortcut --no-menu removes it.`
314
+ : `It starts at every login (${plist}), but launchctl could not start it now (${String(up.stderr).trim() || up.status}) — open it from ${path.dirname(menuApp)}.`);
315
+ }
316
+ } else if (!noOpen) {
317
+ const opened = spawnSync("open", ["-a", menuApp], { encoding: "utf8", timeout: 20000 });
318
+ said.push(opened.status === 0 ? "It is in your menu bar now (the B); beatrina shortcut --menu --login makes it start at every login, --no-menu removes it."
319
+ : `It could not be opened (${String(opened.stderr).trim() || opened.status}); open it from ${path.dirname(menuApp)}.`);
320
+ }
321
+ return said;
322
+ }
323
+
324
+ /**
325
+ * `beatrina shortcut --no-menu`: the login agent, the running menu and the bundle, gone. Nothing
326
+ * else changes — Beatrina.app, the file types and the sessions are untouched.
327
+ */
328
+ export function removeMenu({ dir = "", platform = process.platform } = {}) {
329
+ if (platform !== "darwin") return { said: ["The menu-bar item is macOS only; nothing to remove."] };
330
+ const said = [];
331
+ const menuApp = path.join(dir || path.join(os.homedir(), "Applications"), MENU_NAME);
332
+ const plist = menuAgentPlist();
333
+ spawnSync("launchctl", ["bootout", `${launchDomain()}/${menuLabel()}`], { timeout: 10000 });
334
+ if (fs.existsSync(plist)) { fs.rmSync(plist, { force: true }); said.push(`Removed the login item ${plist}.`); }
335
+ spawnSync("osascript", ["-e", `tell application id "${MENU_BUNDLE_ID}" to quit`], { timeout: 10000 });
336
+ if (fs.existsSync(menuApp)) {
337
+ if (fs.existsSync(LSREGISTER)) spawnSync(LSREGISTER, ["-u", menuApp]);
338
+ fs.rmSync(menuApp, { recursive: true, force: true });
339
+ said.push(`Removed ${menuApp}.`);
340
+ }
341
+ if (!said.length) said.push("No Beatrina menu was installed; nothing to remove.");
342
+ said.push("Beatrina.app, the file types and any running session are as they were.");
343
+ return { said };
344
+ }
345
+
346
+ /**
347
+ * Make the icon (and, with makeDefault, the default .qmd app; with menu, the menu-bar item).
348
+ * BEATRINA_BUNDLE_ID substitutes the bundle id (the suites build a scratch Beatrina.app under a
349
+ * scratch id, so the one in ~/Applications is never a second copy of theirs).
350
+ */
351
+ export function makeShortcut({ cli, dir = "", port = "", makeDefault = false, noOpen = false, platform = process.platform,
352
+ bundleId = process.env.BEATRINA_BUNDLE_ID || BUNDLE_ID, menu = false, login = false, menuSource = "" }) {
353
+ if (platform === "darwin") {
354
+ const made = macShortcut({ cli, dir, port, makeDefault, noOpen, bundleId });
355
+ const menuApp = path.join(dir || path.join(os.homedir(), "Applications"), MENU_NAME);
356
+ if (menu) made.said.push(...installMenu({ cli, dir, login, noOpen, menuSource }));
357
+ else if (fs.existsSync(path.join(menuApp, "Contents", "MacOS", "Beatrina Menu"))) {
358
+ writeMenuCommand(menuApp, cli);
359
+ made.said.push(`${MENU_NAME} in ${path.dirname(menuApp)} now runs this beatrina too.`);
360
+ }
361
+ return made;
362
+ }
363
+ const said = menu ? ["The menu-bar item is macOS only; the rest was done."] : [];
364
+ const made = platform === "win32" ? windowsShortcut({ cli, port, makeDefault }) : linuxShortcut({ cli, port, makeDefault });
365
+ made.said.push(...said);
366
+ return made;
367
+ }
368
+
369
+ /**
370
+ * A running session of this app, or null — the door every double-click, icon and menu row goes through.
371
+ *
372
+ * A thin wrapper over `listSessions` (sessions.mjs), so `beatrina status`, `stop`, `doctor`, the menu
373
+ * and this one door read ONE discovery: the runtime records of the asked app whose /health proves the
374
+ * same session (host and boot instant). With no `port`, the NEWEST `started` first — the rule
375
+ * launch.sh applies to a delivered document — so a double-click with two sessions running lands in the
376
+ * one started most recently, not on the lowest port readdir happens to list first. With `port`, that
377
+ * session or null. The row carries what the record does (port, url, file, pid, started, document…).
378
+ */
379
+ export async function liveSession({ app = "beatrina", runtimeDir = null, port = "" } = {}) {
380
+ // Records live in ~/.beatrina/run, and in the older directory for a session an older build started;
381
+ // an explicit BEATRINA_RUNTIME_DIR, under either spelling (or the `runtimeDir` option), is read alone.
382
+ const rows = await listSessions({ dir: runtimeDir, apps: new Set([app]) });
383
+ const startedAt = (r) => Date.parse(r.started) || 0;
384
+ const live = rows.filter((r) => r.live && (!port || String(r.port) === String(port)))
385
+ .sort((a, b) => startedAt(b) - startedAt(a) || a.port - b.port);
386
+ return live[0] || null;
179
387
  }
@@ -4,8 +4,8 @@
4
4
  // At most once a day per user (the last check is recorded in a small JSON file, which the host reads
5
5
  // back to put `update_available` on /health — host/update-record.mjs), with a short timeout, and never
6
6
  // in the way: a registry that cannot be reached, an unpublished package (404) or a garbled answer all
7
- // mean "nothing to say". BEATRINA_NO_UPDATE_CHECK=1 or CARMAR_NO_UPDATE_CHECK=1 turns it off (the
8
- // battery runner sets the second). BEATRINA_REGISTRY points at another registry (tests).
7
+ // mean "nothing to say". BEATRINA_NO_UPDATE_CHECK=1 (or its older spelling) turns it off (the
8
+ // battery runner sets it). BEATRINA_REGISTRY points at another registry (tests).
9
9
 
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
package/build-info.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.8.7","commit":"b7aa1255558293d1e2b47a4799a344fde0846b2e","dirty":false}
1
+ {"version":"0.9.43","commit":"ce1dbc09721024a43c3666122e3e0759a24b294f","dirty":false}
@@ -39,10 +39,13 @@ import vm from "node:vm";
39
39
  import { createRequire } from "node:module";
40
40
  import { persistDeclarations } from "./persist.mjs";
41
41
  import { DocumentExecError, DocumentSetups, FEATURE } from "./document-exec.mjs";
42
+ // host/ sits beside engines/ in every distribution (tools/npm/build-npm.mjs).
43
+ import { readEnv } from "../../host/env-names.mjs";
42
44
 
43
- const SENTINEL = process.env.CARMAR_SENTINEL || "";
44
- const CMD_TAG = process.env.CARMAR_CMD_TAG || "";
45
- const HANDOFF_DIR = process.env.CARMAR_HANDOFF_DIR || "";
45
+ // BEATRINA_* first, the old CARMAR_* spelling second (host/env-names.mjs).
46
+ const SENTINEL = readEnv(process.env, "SENTINEL");
47
+ const CMD_TAG = readEnv(process.env, "CMD_TAG");
48
+ const HANDOFF_DIR = readEnv(process.env, "HANDOFF_DIR");
46
49
  const COMMANDS = ["exec", "wd", "doctor", "env", "rm", "parse", "complete"];
47
50
  const PREVIEW_ROWS = 200;
48
51
  const PREVIEW_COLS = 20;
@@ -1,11 +1,12 @@
1
1
  """adapter.py — Jupyter messages in, Beatrix frames out.
2
2
 
3
- Ported from `../carmar-python/py/carmapy/kernel_adapter.py` (read 2026-09-14).
3
+ Ported from carmapy's `py/carmapy/kernel_adapter.py` (read 2026-09-14; that
4
+ repository is closed — github.com/mohsaqr/carmar-python, see docs/python-kernel-records/).
4
5
  It does not evaluate anything itself; `ipykernel` does that, in its own OS
5
6
  process, and this translates.
6
7
 
7
8
  THE RULE THAT COST THREE WRONG MEASUREMENTS OVER THERE
8
- (`../carmar-python/notes/probes/RESULTS.md` P1):
9
+ (`docs/python-kernel-records/probes/RESULTS.md` P1):
9
10
 
10
11
  A cell is finished when iopub reports `status: idle` for its parent
11
12
  msg_id — NEVER when execute_reply arrives.
@@ -37,14 +38,14 @@ from typing import Awaitable, Callable
37
38
 
38
39
  from jupyter_client.manager import AsyncKernelManager
39
40
 
40
- from bootstrap import BOOTSTRAP, CARMAR_MIME, CONTROL_MIME, REPLY_MIME
41
+ from bootstrap import BOOTSTRAP, BEATRINA_MIME, CONTROL_MIME, REPLY_MIME
41
42
 
42
43
  #: debugger.py's PROMPT. Restated, not imported: debugger.py is kernel-side
43
44
  #: source and is never imported into this process. test/host-python-debug
44
45
  #: pins the two spellings against each other.
45
- DEBUG_PROMPT = "\x1bcarmar-debug\x1b"
46
- #: debugger.py's CarmarDebugAbort, as ipykernel names it on the error message.
47
- DEBUG_ABORT = "CarmarDebugAbort"
46
+ DEBUG_PROMPT = "\x1bbeatrina-debug\x1b"
47
+ #: debugger.py's BeatrinaDebugAbort, as ipykernel names it on the error message.
48
+ DEBUG_ABORT = "BeatrinaDebugAbort"
48
49
 
49
50
  Emit = Callable[[dict], Awaitable[None]]
50
51
 
@@ -56,7 +57,7 @@ class PythonSession:
56
57
  """One Python session: the notebook's `worker.R` equivalent.
57
58
 
58
59
  Owns exactly one ipykernel process. Not a pool, not a registry — one
59
- document, one kernel, which is both Jupyter's rule and CarmaR's.
60
+ document, one kernel, which is both Jupyter's rule and Beatrina's.
60
61
  """
61
62
 
62
63
  def __init__(self, kernel_name: str = "python3", on_input=None, on_debug=None):
@@ -219,7 +220,7 @@ class PythonSession:
219
220
  try:
220
221
  if srcname:
221
222
  spec = json.dumps(json.dumps({"srcname": srcname, "breaks": breaks or {}}))
222
- prep = self.kc.execute(f"__carmar_debug_prepare({spec})",
223
+ prep = self.kc.execute(f"__beatrina_debug_prepare({spec})",
223
224
  store_history=False, allow_stdin=False)
224
225
  await self._drain_until_idle(prep, None, None, timeout=30)
225
226
  msg_id = self.kc.execute(code, store_history=store_history,
@@ -250,13 +251,13 @@ class PythonSession:
250
251
 
251
252
  The wire never carries code. `spec` is JSON, passed as ONE argument to
252
253
  a function the bootstrap installed; the kernel-side half parses it. A
253
- composed `__carmapy_view(df, sort='...')` string would be the browser
254
+ composed `__beatrina_view(df, sort='...')` string would be the browser
254
255
  writing Python, which is the door this design does not have.
255
256
  """
256
257
  async with self._chan:
257
258
  self._reply = None
258
259
  payload = json.dumps(json.dumps(spec, allow_nan=False))
259
- code = f"__carmapy_reply({op!r}, {payload})"
260
+ code = f"__beatrina_reply({op!r}, {payload})"
260
261
  msg_id = self.kc.execute(code, store_history=False, allow_stdin=False)
261
262
  await self._drain_until_idle(msg_id, None, None, timeout=300)
262
263
  reply, self._reply = self._reply, None
@@ -403,7 +404,7 @@ class PythonSession:
403
404
  self.interrupted = True
404
405
  return []
405
406
  # The debugger's Stop: R reports it as `interrupted` with the
406
- # message "Stopped from the debugger" (worker.R carmar_abort_cell),
407
+ # message "Stopped from the debugger" (worker.R beatrina_abort_cell),
407
408
  # and so does this — no traceback for a button the user pressed.
408
409
  if ename == DEBUG_ABORT:
409
410
  self.interrupted = True
@@ -449,8 +450,8 @@ class PythonSession:
449
450
  self._reply = json.loads(payload) if isinstance(payload, str) else payload
450
451
  return []
451
452
 
452
- if CARMAR_MIME in data:
453
- payload = data[CARMAR_MIME]
453
+ if BEATRINA_MIME in data:
454
+ payload = data[BEATRINA_MIME]
454
455
  if isinstance(payload, str):
455
456
  payload = json.loads(payload)
456
457
  return [{"type": "dataframe", "id": wire_id, "source": None, **payload}]
@@ -8,7 +8,8 @@ and that must not take syntax checking, navigation and rename down with it.
8
8
  browser ⇅ host/main.mjs ⇅ ipykernel — evaluates everything
9
9
  ⇅ analyze.py — evaluates NOTHING
10
10
 
11
- Ported from carmapy's analyze.py (../carmar-python/py/carmapy/analyze.py), with
11
+ Ported from carmapy's analyze.py (`py/carmapy/analyze.py` in the closed
12
+ repository github.com/mohsaqr/carmar-python, see docs/python-kernel-records/), with
12
13
  one deliberate change recorded in docs/wp/wp8a-python-analyzer.md: the
13
14
  reference index is built from `ast`, not from `jedi`, and jedi is not imported.
14
15
 
@@ -69,7 +70,8 @@ import warnings
69
70
 
70
71
  def _budget() -> float:
71
72
  try:
72
- v = float(os.environ.get("CARMAR_ANALYZE_BUDGET", "3"))
73
+ v = float(os.environ.get("BEATRINA_ANALYZE_BUDGET")
74
+ or os.environ.get("CARMAR_ANALYZE_BUDGET", "3")) # beatrina-names: keep
73
75
  except ValueError:
74
76
  v = 3.0
75
77
  if v != v or v in (float("inf"), float("-inf")):