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
@@ -0,0 +1,404 @@
1
+ // choose.mjs — the operating system's own Open / Save / Folder panel, opened
2
+ // by the HOST and answered while R is busy.
3
+ //
4
+ // One op, `choose`: `{mode: "file"|"files"|"dir"|"save", prompt?, start?,
5
+ // defaultName?, accept?: [".qmd", …]}`. The reply is exactly one of `{path}`
6
+ // / `{paths}` (absolute, canonical), `{cancelled: true}`, `{unsupported: true,
7
+ // reason}` (no tool, no display, the panel switched off) or `{error, busy?}`
8
+ // (a second panel while one is open; a class or agent refusal; a tool that
9
+ // failed, with its first stderr line; an answer that cannot be used — a file
10
+ // where a folder was asked).
11
+ //
12
+ // WHY THE HOST, AND WHY AGAIN. The panel used to be opened by R
13
+ // (`spike/chooser.R`, deleted 2026-09-09 by docs/document-model-plan.md D4):
14
+ // it queued behind a running cell, it talked to System Events — an Apple
15
+ // event, which is what put the Automation prompt in front of every Chrome
16
+ // user — and it read "cannot show a panel" and "the person pressed Cancel"
17
+ // as the same silent non-zero exit. This plane answers from the supervisor
18
+ // (a forty-second fit does not delay File ▸ Open), raises the panel with a
19
+ // BARE `activate` — osascript's own process, no `tell application …`, no
20
+ // Apple event, no prompt, no plist key — and classifies cancel PER TOOL.
21
+ //
22
+ // The pure half (`chooseCommand`, `readChooserResult`) builds argument
23
+ // vectors and reads results without a screen, so every adapter is tested on
24
+ // every platform; only `createPlane` spawns. Never a shell string: the prompt
25
+ // and the start folder reach AppleScript and PowerShell as SOURCE, escaped
26
+ // as the retired chooser.R escaped them, and zenity/kdialog as plain argv.
27
+ //
28
+ // NO TIMEOUT ON A PANEL. A person may read a folder for ten minutes; the
29
+ // child lives until they answer, the page's socket closes (`onClose` kills
30
+ // it — a panel nobody can answer is worse than one that vanishes), or the
31
+ // kernel stops (`shutdown`). `busy()` holds the idle clock open meanwhile.
32
+ // One panel at a time, by a module lock: a second ask is `{error, busy}`
33
+ // and the first still answers.
34
+ //
35
+ // Page-only in both senses (a panel is a UI gesture on THIS person's screen)
36
+ // and refused to agents. `BEATRINA_NO_NATIVE_DIALOG` (any value) switches the
37
+ // panel off: `choose` is then not advertised in `ready.commands` — that is
38
+ // the page's whole face of the flag — and answers `{unsupported}` if asked
39
+ // anyway. Binary overrides for tests and odd machines: BEATRINA_OSASCRIPT,
40
+ // BEATRINA_POWERSHELL, BEATRINA_ZENITY, BEATRINA_KDIALOG. A named binary that
41
+ // is missing is an `error` sentence the page shows, never "unsupported": a
42
+ // misconfiguration must be said, not silently routed around.
43
+
44
+ import fs from "node:fs";
45
+ import path from "node:path";
46
+ import { spawn } from "node:child_process";
47
+ import { envSetName, readEnv } from "../env-names.mjs";
48
+ import { whichSync } from "../which.mjs";
49
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
50
+ import { canonicalPath, fsAbs, makeRoot } from "./files.mjs";
51
+
52
+ export const CHOOSE_MODES = Object.freeze(["file", "files", "dir", "save"]);
53
+ const OPS = ["choose"];
54
+ /** The env suffix that overrides each tool's binary (BEATRINA_<suffix>). */
55
+ const TOOL_ENV = Object.freeze({ osascript: "OSASCRIPT", powershell: "POWERSHELL", zenity: "ZENITY", kdialog: "KDIALOG" });
56
+ /** Output kept per stream: a path list is small; a runaway tool is not. */
57
+ const OUTPUT_CAP = 64 * 1024;
58
+ const PROMPT_MAX = 120;
59
+ const NAME_MAX = 255;
60
+
61
+ // eslint-disable-next-line no-control-regex
62
+ const oneLine = (x, max) => String(x ?? "").replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
63
+
64
+ /** An AppleScript double-quoted literal: the two characters that end or escape one, and no line breaks. */
65
+ export function appleScriptString(x) {
66
+ return `"${oneLine(x, 4096).replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
67
+ }
68
+
69
+ /** A PowerShell single-quoted literal: only the quote itself is special, doubled. */
70
+ export function powerShellString(x) {
71
+ return `'${oneLine(x, 4096).replace(/'/g, "''")}'`;
72
+ }
73
+
74
+ /** The `-EncodedCommand` form PowerShell reads: UTF-16LE, base64. */
75
+ export function encodePowerShell(script) {
76
+ return Buffer.from(script, "utf16le").toString("base64");
77
+ }
78
+
79
+ /**
80
+ * Read one request off the wire into the five fields the adapters take, or
81
+ * say what is wrong with it. Nothing else travels: no path is executed, no
82
+ * name reaches a shell.
83
+ *
84
+ * @returns {{mode:string, prompt:string, start:string, defaultName:string, accept:string[]}|{error:string}}
85
+ */
86
+ export function readChooseRequest(cmd) {
87
+ const mode = typeof cmd.mode === "string" ? cmd.mode : "";
88
+ if (!CHOOSE_MODES.includes(mode)) return { error: `\`mode\` must be one of ${CHOOSE_MODES.join(", ")}.` };
89
+ const prompt = oneLine(cmd.prompt, PROMPT_MAX) || { file: "Open", files: "Open", dir: "Choose Folder", save: "Save" }[mode];
90
+ const start = typeof cmd.start === "string" ? cmd.start.trim().slice(0, 4096) : "";
91
+ const defaultName = oneLine(cmd.defaultName, NAME_MAX);
92
+ if (/[\\/]/.test(defaultName)) return { error: "`defaultName` is a file name, not a path." };
93
+ const accept = (Array.isArray(cmd.accept) ? cmd.accept : [])
94
+ .filter((e) => typeof e === "string" && /^\.[A-Za-z0-9]{1,16}$/.test(e))
95
+ .slice(0, 32);
96
+ return { mode, prompt, start, defaultName, accept };
97
+ }
98
+
99
+ /** Which binary runs `tool`: an override that exists, else PATH, else "" — or a sentence about an override that does not exist. */
100
+ function binaryFor(tool, env, which) {
101
+ const override = readEnv(env, TOOL_ENV[tool]).trim();
102
+ if (override) {
103
+ let ok = false;
104
+ try { fs.accessSync(override, fs.constants.X_OK); ok = fs.statSync(override).isFile(); } catch { ok = false; }
105
+ if (ok) return { path: override };
106
+ return { error: `${envSetName(env, TOOL_ENV[tool])} names ${override}, which is not an executable file; unset it or point it at ${tool}.` };
107
+ }
108
+ return { path: which(tool) || "" };
109
+ }
110
+
111
+ /** `default location` / `InitialDirectory` / `--filename` only for a folder that exists. */
112
+ const defaultDirExists = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
113
+
114
+ /**
115
+ * The argument vector that opens this platform's panel for `req`.
116
+ *
117
+ * @param {{mode:string, prompt?:string, start?:string, defaultName?:string, accept?:string[]}} req
118
+ * `start` is already absolute (the plane resolves it against the worker's cwd)
119
+ * @param {{platform?:string, env?:object, which?:(name:string)=>string, dirExists?:(p:string)=>boolean}} [deps]
120
+ * @returns {{tool:string, command:string, args:string[], script?:string}
121
+ * | {unsupported:true, reason:string} | {error:string}}
122
+ */
123
+ export function chooseCommand(req, deps = {}) {
124
+ const platform = deps.platform || process.platform;
125
+ const env = deps.env || process.env;
126
+ const which = deps.which || ((name) => whichSync(name, env.source || env));
127
+ const dirExists = deps.dirExists || defaultDirExists;
128
+ const mode = req.mode;
129
+ const prompt = oneLine(req.prompt, PROMPT_MAX) || "Choose";
130
+ const start = typeof req.start === "string" && req.start && dirExists(req.start) ? req.start : "";
131
+ const name = mode === "save" ? oneLine(req.defaultName, NAME_MAX) : "";
132
+ const accept = Array.isArray(req.accept) ? req.accept.filter((e) => /^\.[A-Za-z0-9]{1,16}$/.test(e)) : [];
133
+ const exts = accept.map((e) => e.slice(1));
134
+
135
+ if (platform === "darwin") {
136
+ const bin = binaryFor("osascript", env, which);
137
+ if (bin.error) return { error: bin.error };
138
+ if (!bin.path) return { unsupported: true, reason: "osascript was not found on PATH, so macOS cannot show its file panel." };
139
+ const verb = mode === "dir" ? "choose folder" : mode === "save" ? "choose file name" : "choose file";
140
+ const parts = [`${verb} with prompt ${appleScriptString(prompt)}`];
141
+ // `of type` only for a door that names types: File ▸ Open (kind "all") sends none.
142
+ if ((mode === "file" || mode === "files") && exts.length) parts.push(`of type {${exts.map(appleScriptString).join(", ")}}`);
143
+ if (mode === "save" && name) parts.push(`default name ${appleScriptString(name)}`);
144
+ // Parentheses are syntax: `default location POSIX file` is read as adjacent
145
+ // parameter/class names; coercing the POSIX path first gives the alias.
146
+ if (start) parts.push(`default location (POSIX file ${appleScriptString(start)})`);
147
+ if (mode === "files") parts.push("with multiple selections allowed");
148
+ // A bare `activate` raises osascript's own process — no `tell application`,
149
+ // no Apple event, no Automation prompt. It must stay bare.
150
+ const script = mode === "files"
151
+ ? ["activate",
152
+ `set theItems to ${parts.join(" ")}`,
153
+ "set out to \"\"",
154
+ "repeat with anItem in theItems",
155
+ " set out to out & POSIX path of anItem & linefeed",
156
+ "end repeat",
157
+ "out"].join("\n")
158
+ : ["activate", `POSIX path of (${parts.join(" ")})`].join("\n");
159
+ return { tool: "osascript", command: bin.path, args: ["-e", script], script };
160
+ }
161
+
162
+ if (platform === "win32") {
163
+ const bin = binaryFor("powershell", env, which);
164
+ if (bin.error) return { error: bin.error };
165
+ const found = bin.path || which("powershell.exe");
166
+ if (!found) return { unsupported: true, reason: "PowerShell was not found on PATH, so Windows cannot show its file panel." };
167
+ const filter = exts.length
168
+ ? `Matching files (${exts.map((e) => `*.${e}`).join(";")})|${exts.map((e) => `*.${e}`).join(";")}|All files (*.*)|*.*`
169
+ : "All files (*.*)|*.*";
170
+ const lines = [
171
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
172
+ "Add-Type -AssemblyName System.Windows.Forms",
173
+ // An owner form that is TopMost is what puts the panel over the browser
174
+ // window; without one it opens behind it and the page looks hung.
175
+ "$f = New-Object System.Windows.Forms.Form; $f.TopMost = $true",
176
+ ];
177
+ if (mode === "dir") {
178
+ lines.push("$d = New-Object System.Windows.Forms.FolderBrowserDialog",
179
+ `$d.Description = ${powerShellString(prompt)}`);
180
+ if (start) lines.push(`$d.SelectedPath = ${powerShellString(start)}`);
181
+ lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }");
182
+ } else if (mode === "save") {
183
+ lines.push("$d = New-Object System.Windows.Forms.SaveFileDialog",
184
+ `$d.Title = ${powerShellString(prompt)}`, `$d.Filter = ${powerShellString(filter)}`,
185
+ "$d.AddExtension = $true", "$d.OverwritePrompt = $true");
186
+ if (exts.length) lines.push(`$d.DefaultExt = ${powerShellString(exts[0])}`);
187
+ if (start) lines.push(`$d.InitialDirectory = ${powerShellString(start)}`);
188
+ if (name) lines.push(`$d.FileName = ${powerShellString(name)}`);
189
+ lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.FileName) }");
190
+ } else {
191
+ lines.push("$d = New-Object System.Windows.Forms.OpenFileDialog",
192
+ `$d.Title = ${powerShellString(prompt)}`, `$d.Filter = ${powerShellString(filter)}`,
193
+ `$d.Multiselect = ${mode === "files" ? "$true" : "$false"}`);
194
+ if (start) lines.push(`$d.InitialDirectory = ${powerShellString(start)}`);
195
+ lines.push("if ($d.ShowDialog($f) -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write(($d.FileNames -join \"`n\")) }");
196
+ }
197
+ const script = lines.join("\n");
198
+ return { tool: "powershell", command: found,
199
+ args: ["-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShell(script)], script };
200
+ }
201
+
202
+ // Linux and everything else: a desktop chooser, if there is a desktop.
203
+ const display = readEnvPlain(env, "DISPLAY") || readEnvPlain(env, "WAYLAND_DISPLAY");
204
+ if (!display) return { unsupported: true, reason: "No display (DISPLAY and WAYLAND_DISPLAY are unset), so no file panel can be shown." };
205
+ const zen = binaryFor("zenity", env, which);
206
+ if (zen.error) return { error: zen.error };
207
+ if (zen.path) {
208
+ const args = ["--file-selection", `--title=${prompt}`];
209
+ if (mode === "dir") args.push("--directory");
210
+ if (mode === "save") args.push("--save", "--confirm-overwrite");
211
+ if (mode === "files") args.push("--multiple", "--separator=\n");
212
+ if (start) args.push(`--filename=${start.replace(/\/+$/, "")}/${mode === "save" ? name : ""}`);
213
+ else if (mode === "save" && name) args.push(`--filename=${name}`);
214
+ if ((mode === "file" || mode === "files") && exts.length) {
215
+ args.push(`--file-filter=Matching files | ${exts.map((e) => `*.${e}`).join(" ")}`, "--file-filter=All files | *");
216
+ }
217
+ return { tool: "zenity", command: zen.path, args };
218
+ }
219
+ const kd = binaryFor("kdialog", env, which);
220
+ if (kd.error) return { error: kd.error };
221
+ if (kd.path) {
222
+ const verb = mode === "dir" ? "--getexistingdirectory" : mode === "save" ? "--getsavefilename" : "--getopenfilename";
223
+ const at = start ? (mode === "save" && name ? `${start.replace(/\/+$/, "")}/${name}` : start) : (mode === "save" && name ? name : ".");
224
+ const args = ["--title", prompt];
225
+ if (mode === "files") args.push("--multiple", "--separate-output");
226
+ args.push(verb, at);
227
+ if ((mode === "file" || mode === "files") && exts.length) args.push(`${exts.map((e) => `*.${e}`).join(" ")}|Matching files\n*|All files`);
228
+ return { tool: "kdialog", command: kd.path, args };
229
+ }
230
+ return { unsupported: true, reason: "Neither zenity nor kdialog was found on PATH, so no file panel can be shown." };
231
+ }
232
+
233
+ /** A non-product variable, read from a plain object or the host's env reader. */
234
+ function readEnvPlain(env, name) {
235
+ if (typeof env === "function") return String(env(name, "") || "");
236
+ return env && env[name] != null ? String(env[name]) : "";
237
+ }
238
+
239
+ /**
240
+ * What the tool said, classified — per tool, because "non-zero and silent"
241
+ * means Cancel for zenity and kdialog and "could not show a panel" for
242
+ * osascript, which always names a real cancel (`User canceled. (-128)`).
243
+ *
244
+ * @param {string} tool osascript | powershell | zenity | kdialog
245
+ * @param {string} mode
246
+ * @param {{status:number|null, signal?:string|null, stdout?:string, stderr?:string}} out
247
+ * @returns {{path:string}|{paths:string[]}|{cancelled:true}|{error:string}}
248
+ */
249
+ export function readChooserResult(tool, mode, { status, signal = null, stdout = "", stderr = "" }) {
250
+ const lines = String(stdout || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
251
+ const said = String(stderr || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
252
+ if (status == null) return { error: `The file panel was closed before it answered${signal ? ` (${signal})` : ""}.` };
253
+ const text = `${said.join(" ")} ${lines.join(" ")}`;
254
+ if (status !== 0) {
255
+ if (tool === "osascript" && /user cancell?ed|\(-128\)/i.test(text)) return { cancelled: true };
256
+ if ((tool === "zenity" || tool === "kdialog") && status === 1 && !lines.length) return { cancelled: true };
257
+ return { error: `The file panel failed: ${said[0] || lines[0] || `exit status ${status}`}` };
258
+ }
259
+ if (!lines.length) {
260
+ // A PowerShell dialog writes nothing on Cancel; the others exit non-zero.
261
+ if (tool === "powershell") return { cancelled: true };
262
+ return { error: "The file panel returned nothing readable." };
263
+ }
264
+ if (mode === "files") return { paths: lines };
265
+ // A GTK warning can share zenity's stream; the path is the LAST line.
266
+ return { path: lines[lines.length - 1] };
267
+ }
268
+
269
+ /**
270
+ * Check an answer before the page sees it: absolute, existing as the kind
271
+ * asked for (the folder for a save), canonical, and inside BEATRINA_ROOT when
272
+ * one is set — refused here with the files plane's own sentence, one step
273
+ * before `readfile`/`writefile` would refuse the same path.
274
+ *
275
+ * @returns {{path:string}|{paths:string[]}|{error:string}}
276
+ */
277
+ export function validateChosen(mode, answer, { root }) {
278
+ const one = (raw) => {
279
+ const p = String(raw).replace(/[\\/]+$/, "") || String(raw);
280
+ if (!path.isAbsolute(p)) return { error: `The file panel answered with a relative path (${p}).` };
281
+ const full = canonicalPath(p);
282
+ let st = null;
283
+ try { st = fs.statSync(full); } catch { st = null; }
284
+ if (mode === "dir") {
285
+ if (!st || !st.isDirectory()) return { error: `${full} is not a folder.` };
286
+ } else if (mode === "save") {
287
+ let parent = null;
288
+ try { parent = fs.statSync(path.dirname(full)); } catch { parent = null; }
289
+ if (!parent || !parent.isDirectory()) return { error: `The folder ${path.dirname(full)} does not exist.` };
290
+ if (st && st.isDirectory()) return { error: `${full} is a folder, not a file name.` };
291
+ } else if (!st || !st.isFile()) {
292
+ return { error: `${full} is not a file.` };
293
+ }
294
+ if (!root.within(full)) return { error: root.outsideMsg() };
295
+ return { path: full };
296
+ };
297
+ if (mode === "files") {
298
+ const paths = [];
299
+ for (const raw of answer.paths || []) { // every path is checked; the first bad one is the answer
300
+ const r = one(raw);
301
+ if (r.error) return r;
302
+ paths.push(r.path);
303
+ }
304
+ if (!paths.length) return { error: "The file panel returned nothing readable." };
305
+ return { paths };
306
+ }
307
+ return one(answer.path);
308
+ }
309
+
310
+ export function createPlane({ env, audit }) {
311
+ const flagged = env("BEATRINA_NO_NATIVE_DIALOG") !== "";
312
+ const offReason = `The OS file panel is switched off (${envSetName(env.source || process.env, "NO_NATIVE_DIALOG")} is set).`;
313
+ // Decided ONCE, at start: whether this machine can show a panel at all.
314
+ // `{error}` (an override naming a missing binary) still counts as available
315
+ // so the page asks and the misconfiguration sentence reaches the person.
316
+ const probe = flagged ? { unsupported: true, reason: offReason } : chooseCommand({ mode: "file", prompt: "Open" }, { env });
317
+ const available = probe.unsupported !== true;
318
+ const root = makeRoot(env("BEATRINA_ROOT"));
319
+ /** The one open panel: {child, rec, id, mode, tool, killed}. */
320
+ let open = null;
321
+ const alive = (child) => child && child.exitCode === null && child.signalCode === null;
322
+ const kill = (why) => {
323
+ if (!open || !alive(open.child)) return;
324
+ open.killed = why;
325
+ try { open.child.kill("SIGKILL"); } catch { /* already gone */ }
326
+ };
327
+
328
+ return {
329
+ name: "choose",
330
+ ops: OPS,
331
+ agentRefused: OPS,
332
+ agentReason: () => "Agents cannot open a file panel on this person's screen; ask the page to open or save the file.",
333
+ commands: available ? OPS : [],
334
+ capabilities: available ? ["native-dialog-v1"] : [],
335
+
336
+ handle(cmd, rec, ctx) {
337
+ if (rec.role !== "page" || !ctx.scalarChr(cmd.id)) return undefined;
338
+ const reply = (fields) => {
339
+ try { rec.ws.send(ctx.enc({ type: "choose", id: cmd.id, ...fields })); } catch { /* the page has gone; nothing to tell */ }
340
+ };
341
+ if (!PAGE_ONLY_CLASSES.includes(rec.class)) {
342
+ audit("choose-refused", { reason: "class", class: rec.class || "unknown" });
343
+ return reply({ error: "Only the local notebook page may open the file panel." });
344
+ }
345
+ const req = readChooseRequest(cmd);
346
+ if (req.error) return reply({ error: req.error });
347
+ if (flagged) return reply({ unsupported: true, reason: offReason });
348
+ if (open && alive(open.child)) {
349
+ audit("choose-refused", { reason: "busy" });
350
+ return reply({ error: "A file panel is already open — answer it first.", busy: true });
351
+ }
352
+ // A relative start is the worker's business: resolved against the cwd it
353
+ // last reported, exactly as the files plane resolves a relative path.
354
+ const start = req.start ? fsAbs(req.start, ctx.plane.workerWd) : "";
355
+ const spec = chooseCommand({ ...req, start }, { env });
356
+ if (spec.unsupported) return reply({ unsupported: true, reason: spec.reason });
357
+ if (spec.error) return reply({ error: spec.error });
358
+
359
+ let child;
360
+ try {
361
+ child = spawn(spec.command, spec.args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
362
+ } catch (e) {
363
+ return reply({ error: `The file panel could not start: ${e.message}` });
364
+ }
365
+ const mine = { child, rec, id: cmd.id, mode: req.mode, tool: spec.tool, killed: "" };
366
+ open = mine;
367
+ let stdout = "";
368
+ let stderr = "";
369
+ child.stdout.setEncoding("utf8");
370
+ child.stderr.setEncoding("utf8");
371
+ child.stdout.on("data", (d) => { if (stdout.length < OUTPUT_CAP) stdout += d; });
372
+ child.stderr.on("data", (d) => { if (stderr.length < OUTPUT_CAP) stderr += d; });
373
+ let done = false;
374
+ const finish = (answer) => {
375
+ if (done) return;
376
+ done = true;
377
+ if (open === mine) open = null;
378
+ const outcome = answer.cancelled ? "cancelled" : answer.error ? "error" : "chosen";
379
+ audit("choose-answered", { mode: req.mode, tool: spec.tool, outcome, ...(mine.killed ? { killed: mine.killed } : {}) });
380
+ // A page that left while the panel was up has nobody to answer.
381
+ if (ctx.sockets.includes(rec)) reply(answer);
382
+ };
383
+ child.on("error", (e) => finish({ error: `The file panel could not start: ${e.message}` }));
384
+ child.on("close", (status, signal) => {
385
+ if (mine.killed) return finish({ error: `The file panel was closed (${mine.killed}).` });
386
+ const read = readChooserResult(spec.tool, req.mode, { status, signal, stdout, stderr });
387
+ if (read.cancelled || read.error) return finish(read);
388
+ finish(validateChosen(req.mode, read, { root }));
389
+ });
390
+ return undefined;
391
+ },
392
+
393
+ /** A panel that is up holds the idle clock open: a person is looking at it. */
394
+ busy: () => open != null && alive(open.child),
395
+
396
+ /** The page that asked has gone: a panel nobody can answer is worse than one that vanishes. */
397
+ onClose(rec) { if (open && open.rec === rec) kill("page closed"); },
398
+
399
+ /** The kernel is going; no panel may outlive it. */
400
+ shutdown() { kill("kernel stopping"); },
401
+ };
402
+ }
403
+
404
+ export default { createPlane };
@@ -33,11 +33,13 @@ import os from "node:os";
33
33
  import path from "node:path";
34
34
  import crypto from "node:crypto";
35
35
  import { spawn } from "node:child_process";
36
- import { appId } from "../user-dirs.mjs";
36
+ import { appId, rUserDir } from "../user-dirs.mjs";
37
+ import { PAGE_ONLY_CLASSES } from "../server.mjs";
37
38
  import {
38
39
  CSL_NAME_RE, cslInfo, cslIndexUrl, pluginFetch, pluginIdOk, pluginInstall,
39
- pluginPolicyOnce, pluginPolicyReason, pluginRemove, pluginRows, rUserDir,
40
+ pluginPolicyOnce, pluginPolicyReason, pluginRemove, pluginRows,
40
41
  } from "../plugin-store.mjs";
42
+ import { whichSync } from "../which.mjs";
41
43
 
42
44
  export { cslInfo, cslLooksLikeStyle } from "../plugin-store.mjs";
43
45
 
@@ -53,7 +55,6 @@ const CSL_MAX_INDEX_BYTES = 8000000;
53
55
  const CSL_INDEX_MAX_AGE_DAYS = 7;
54
56
  const CSL_SEARCH_LIMIT = 40;
55
57
 
56
- const PAGE_ONLY_CLASSES = ["served", "file", "local"];
57
58
  const OPS = ["cite", "cite_styles", "csl_place", "csl_search", "csl_import", "csl_remove"];
58
59
  /** Every op but `cite_styles`: a process, a file, or the network. */
59
60
  const AGENT_REFUSED = ["cite", "csl_place", "csl_search", "csl_import", "csl_remove"];
@@ -207,7 +208,6 @@ export function cslRemove(here, policy, env, file) {
207
208
 
208
209
  /* ── pandoc ───────────────────────────────────────────────────────────────── */
209
210
 
210
- const isExecutable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return fs.statSync(p).isFile(); } catch { return false; } };
211
211
 
212
212
  /**
213
213
  * Locate pandoc, the ladder spike/cite.R climbs: an explicit override, what
@@ -219,23 +219,24 @@ const isExecutable = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return
219
219
  * starting an R process to find a binary — the host would spawn a worker to
220
220
  * answer a question about the file system — so the bundle list below carries
221
221
  * the same answers rmarkdown reaches for. On this machine both resolve to the
222
- * same binary; where they could differ, CARMAR_PANDOC settles it.
222
+ * same binary; where they could differ, BEATRINA_PANDOC settles it.
223
223
  *
224
224
  * @returns {string} the path, or "".
225
225
  */
226
226
  export function findPandoc(env) {
227
- const explicit = env("CARMAR_PANDOC", "");
227
+ const explicit = env("BEATRINA_PANDOC", "");
228
228
  if (explicit && fs.existsSync(explicit)) return explicit;
229
229
  const fromEnv = env("RSTUDIO_PANDOC", "");
230
230
  if (fromEnv) {
231
231
  const cand = path.join(fromEnv, process.platform === "win32" ? "pandoc.exe" : "pandoc");
232
232
  if (fs.existsSync(cand)) return cand;
233
233
  }
234
- const exe = process.platform === "win32" ? "pandoc.exe" : "pandoc";
235
- for (const dir of String(env("PATH", "")).split(path.delimiter).filter(Boolean)) {
236
- const cand = path.join(dir, exe);
237
- if (isExecutable(cand)) return cand;
238
- }
234
+ // The PATH walk is host/which.mjs's, which was lifted out precisely so two
235
+ // planes could not disagree about what "on PATH" means. This copy tried only
236
+ // `pandoc.exe` on Windows, where whichSync walks PATHEXT. `env` here is a
237
+ // READER, so PATH is handed over as the object whichSync expects.
238
+ const onPath = whichSync("pandoc", { PATH: env("PATH", "") });
239
+ if (onPath) return onPath;
239
240
  const arch = ["arm64"].includes(process.arch) ? "aarch64" : "x86_64";
240
241
  const bundles = [
241
242
  `/Applications/RStudio.app/Contents/Resources/app/quarto/bin/tools/${arch}/pandoc`,
@@ -327,7 +328,7 @@ export function citeValidate(here, policy, env, cmd) {
327
328
  export function citeDocument(yaml, groups) {
328
329
  const paras = groups.map((g) => {
329
330
  const cite = g.narrative ? `@${g.keys[0]}` : `[${g.keys.map((k) => `@${k}`).join("; ")}]`;
330
- return `[${cite}]{.carmar-cite-group}`;
331
+ return `[${cite}]{.beatrina-cite-group}`;
331
332
  });
332
333
  return `---\n${yaml}\n---\n\n${paras.join("\n\n")}\n`;
333
334
  }
@@ -335,13 +336,13 @@ export function citeDocument(yaml, groups) {
335
336
  /**
336
337
  * Split pandoc's HTML into the group labels and the bibliography.
337
338
  *
338
- * Positional: the i-th `carmar-cite-group` span is the i-th group. A group span
339
+ * Positional: the i-th `beatrina-cite-group` span is the i-th group. A group span
339
340
  * holds nested spans of its own, so the closing tag is found by WALKING span
340
341
  * tags and counting depth, never by a non-greedy match.
341
342
  */
342
343
  export function citeSplit(html, nGroups) {
343
344
  const labels = new Array(nGroups).fill("");
344
- const open = '<span class="carmar-cite-group">';
345
+ const open = '<span class="beatrina-cite-group">';
345
346
  const starts = [];
346
347
  for (let at = html.indexOf(open); at !== -1; at = html.indexOf(open, at + 1)) starts.push(at);
347
348
  for (let i = 0; i < Math.min(starts.length, nGroups); i += 1) {
@@ -369,8 +370,8 @@ export function citeSplit(html, nGroups) {
369
370
 
370
371
  /** Run pandoc over one validated request. */
371
372
  export async function citeRun(bin, valid, timeoutMs = CITE_TIMEOUT_MS) {
372
- if (!bin) return { ok: false, error: "pandoc is not installed, or CarmaR could not find it." };
373
- const input = path.join(os.tmpdir(), `carmar-cite-${crypto.randomBytes(8).toString("hex")}.md`);
373
+ if (!bin) return { ok: false, error: "pandoc is not installed, or Beatrina could not find it." };
374
+ const input = path.join(os.tmpdir(), `beatrina-cite-${crypto.randomBytes(8).toString("hex")}.md`);
374
375
  try {
375
376
  fs.writeFileSync(input, citeDocument(valid.yaml, valid.groups));
376
377
  fs.chmodSync(input, 0o600);
@@ -482,7 +483,7 @@ export function createPlane({ spike, env, audit }) {
482
483
 
483
484
  // csl_place: the style beside the document, as a .bib sits beside a .tex
484
485
  // — what makes `csl: apa.csl` in the front matter render the same style
485
- // with no CarmaR present. A dependent style's parent goes too, or pandoc
486
+ // with no Beatrina present. A dependent style's parent goes too, or pandoc
486
487
  // would go looking for it. NEVER overwrites: a different file of that
487
488
  // name beside the document is the author's, and is reported.
488
489
  const style = cslResolve(here, policy, env, cmd.name);
Binary file