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
@@ -10,15 +10,16 @@ import net from "node:net";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { mirrorEnv, setEnvName } from "../../host/env-names.mjs";
13
14
 
14
15
  export const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
15
16
  export const WIN = process.platform === "win32";
16
17
  export const pause = (ms) => new Promise((r) => setTimeout(r, ms));
17
18
 
18
- /** The installed carmar package's host/main.mjs, asked of R itself. */
19
+ /** The installed R package's host/main.mjs (the package is still named `beatrina`), asked of R itself. */
19
20
  export function installedHost() {
20
21
  const r = spawnSync(WIN ? "Rscript.exe" : "Rscript",
21
- ["-e", "cat(system.file('app', 'host', 'main.mjs', package = 'carmar'))"], { encoding: "utf8" });
22
+ ["-e", "cat(system.file('app', 'host', 'main.mjs', package = 'beatrina'))"], { encoding: "utf8" });
22
23
  return String(r.stdout || "").trim();
23
24
  }
24
25
 
@@ -47,8 +48,11 @@ export function supervisorCommand({ supervisor = "beatrix", host = "", installed
47
48
  fs.copyFileSync(path.join(repo, "kernel.protocol"), path.join(stage, "kernel-protocol"));
48
49
  const version = String(spawnSync(process.execPath, ["-p", "require(process.argv[1]).version", path.join(repo, "notebook.version.cjs")], { encoding: "utf8" }).stdout).trim();
49
50
  fs.writeFileSync(path.join(stage, "kernel-version"), `${version}\n`);
50
- for (const f of fs.readdirSync(path.join(repo, "dist")).filter((x) => /^carmar_V.*\.html$/.test(x))) {
51
+ const checked = spawnSync(process.execPath, [path.join(repo, "tools/artifact-inputs.mjs")], {encoding:"utf8"});
52
+ if (checked.status !== 0) throw new Error(`Cannot stage stale notebook artifacts: ${checked.stderr || checked.error || "build verification failed"}`);
53
+ for (const f of [`beatrina_V${version}.html`, `beatrina_V${version}.beta.min.html`]) {
51
54
  fs.copyFileSync(path.join(repo, "dist", f), path.join(stage, f));
55
+ fs.copyFileSync(path.join(repo, "dist", f + ".inputs.json"), path.join(stage, f + ".inputs.json"));
52
56
  }
53
57
  return { label: "R supervisor (serve.R, retired)", cmd: WIN ? "Rscript.exe" : "Rscript", args: [path.join(stage, "serve.R")] };
54
58
  }
@@ -120,20 +124,57 @@ export function openSocket(port, origin) {
120
124
  }
121
125
 
122
126
 
127
+ /**
128
+ * A port nothing is listening on, from the OS (listen on 0, read, close).
129
+ * `beatrina knit` without --port used to hand startSession an empty port, and
130
+ * the page socket then refused to connect to it — every knit that ran a chunk
131
+ * died before the first one (2026-09-19).
132
+ */
133
+ export function freePort() {
134
+ return new Promise((resolve, reject) => {
135
+ const srv = net.createServer();
136
+ srv.unref();
137
+ srv.on("error", reject);
138
+ srv.listen(0, "127.0.0.1", () => {
139
+ const { port } = srv.address();
140
+ srv.close(() => resolve(port));
141
+ });
142
+ });
143
+ }
144
+
123
145
  /**
124
146
  * Start a supervisor on `port`, open a page socket, and wait for R's ready.
125
147
  * @returns {Promise<object>} the session: frames, until, text, run, ask, ready, startupMs, log, shutdown
126
148
  */
127
149
  export async function startSession({ command, port, cwd, env = {}, readyMs = 90000, logFile = "" }) {
128
150
  const t0 = Date.now();
151
+ // A caller's override may be spelled BEATRINA_* or CARMAR_*; it is applied
152
+ // under BOTH names so it beats the defaults below whichever spelling it used.
153
+ const overrides = {};
154
+ for (const [k, v] of Object.entries(env)) setEnvName(overrides, k, v);
129
155
  const child = spawn(command.cmd, command.args, {
130
156
  cwd,
131
- env: {
132
- ...process.env, CARMAR_PORT: String(port), CARMAR_NO_NATIVE_DIALOG: "1", CARMAR_NO_RUNTIME_FILE: "1",
133
- CARMAR_HISTORY_DIR: path.join(cwd, ".beatrina-history"), CARMAR_NO_UPDATE_CHECK: "1", CARMAR_LINGER: "0",
157
+ // Canonical names, mirrored: a host older than the rename reads CARMAR_*.
158
+ env: mirrorEnv({
159
+ ...process.env, BEATRINA_PORT: String(port), BEATRINA_NO_NATIVE_DIALOG: "1", BEATRINA_NO_RUNTIME_FILE: "1",
160
+ BEATRINA_HISTORY_DIR: path.join(cwd, ".beatrina-history"), BEATRINA_NO_UPDATE_CHECK: "1",
161
+ // A PRIVATE SESSION MUST BE ABLE TO REAP ITSELF.
162
+ //
163
+ // This pair — LINGER=0 (never idle-reap) with NO_RUNTIME_FILE=1 (write
164
+ // no discovery record) — makes a kernel that nothing can find and
165
+ // nothing will ever stop. If the parent dies between spawn and
166
+ // shutdown, it serves forever: the orphan measured on port 4748 (pid
167
+ // 64335, started 2026-09-19) is exactly this, and it is holding a port
168
+ // in the owner's own session band.
169
+ //
170
+ // A POSITIVE linger is the documented opt-in to the idle clock, and a
171
+ // RUNNING TASK BLOCKS IT (spike/settings.R), so a long knit or check is
172
+ // not at risk — only an abandoned one, which is the case we want
173
+ // collected. The caller's `overrides` still win, spelled either way.
174
+ BEATRINA_LINGER: "600",
134
175
  BEATRIX_NO_OPEN: "1", // the `beatrix` command opens a browser unless told not to
135
- ...env,
136
- },
176
+ ...overrides,
177
+ }),
137
178
  stdio: ["ignore", "pipe", "pipe"],
138
179
  windowsHide: true,
139
180
  });
@@ -1,6 +1,6 @@
1
1
  # deployment.R — where this kernel listens, and who is allowed to reach it.
2
2
  #
3
- # CarmaR was written for exactly one deployment: a loopback socket serving one
3
+ # Beatrina was written for exactly one deployment: a loopback socket serving one
4
4
  # person on their own machine. Every trust rule in serve.R is an expression of
5
5
  # that, and one of them INVERTS the moment the socket leaves loopback:
6
6
  #
@@ -24,15 +24,15 @@
24
24
  # The refusals are the point. Binding off loopback is a deployment decision
25
25
  # with consequences the operator must state out loud:
26
26
  #
27
- # · no CARMAR_HOSTS → refuse. An unset Host allow-list off
27
+ # · no BEATRINA_HOSTS → refuse. An unset Host allow-list off
28
28
  # loopback is a DNS-rebinding hole with the
29
29
  # door held open.
30
- # · no authentication in front → refuse, unless CARMAR_ALLOW_UNAUTHENTICATED
30
+ # · no authentication in front → refuse, unless BEATRINA_ALLOW_UNAUTHENTICATED
31
31
  # is set. The variable is deliberately long
32
32
  # and unpleasant to type; it is a decision,
33
33
  # not a default.
34
34
  #
35
- # The intended P0 shape is one CarmaR per person, each as their own OS user on
35
+ # The intended P0 shape is one Beatrina per person, each as their own OS user on
36
36
  # its own port, with TLS and login in a reverse proxy (Caddy + OIDC
37
37
  # forward-auth). serve.R never sees a password; it trusts an identity header,
38
38
  # and only from the proxy address. See docs/server.md.
@@ -65,8 +65,8 @@ is_loopback_bind <- function(bind) tolower(bind %||% "") %in% LOOPBACK_HOSTS
65
65
  #' `user_header`, `proxy_addrs`, and `errors` — a character vector that is
66
66
  #' EMPTY when the posture is safe to serve. A non-empty `errors` must abort
67
67
  #' startup; it is never a warning.
68
- carmar_deployment <- function(port, env = Sys.getenv) {
69
- bind <- trimws(env("CARMAR_BIND", "127.0.0.1"))
68
+ beatrina_deployment <- function(port, env = Sys.getenv) {
69
+ bind <- trimws(env("BEATRINA_BIND", "127.0.0.1"))
70
70
  if (!nzchar(bind)) bind <- "127.0.0.1"
71
71
  loopback <- is_loopback_bind(bind)
72
72
 
@@ -77,9 +77,9 @@ carmar_deployment <- function(port, env = Sys.getenv) {
77
77
  self_origins <- c(sprintf("http://%s:%d", bind, port),
78
78
  sprintf("http://127.0.0.1:%d", port),
79
79
  sprintf("http://localhost:%d", port))
80
- origins <- unique(c(self_origins, split_list(env("CARMAR_ORIGINS", ""))))
80
+ origins <- unique(c(self_origins, split_list(env("BEATRINA_ORIGINS", ""))))
81
81
 
82
- hosts_env <- split_list(env("CARMAR_HOSTS", ""))
82
+ hosts_env <- split_list(env("BEATRINA_HOSTS", ""))
83
83
  default_hosts <- c(sprintf("127.0.0.1:%d", port), sprintf("localhost:%d", port),
84
84
  sprintf("[::1]:%d", port))
85
85
  # A configured host may or may not carry a port. Accept both spellings so an
@@ -97,33 +97,33 @@ carmar_deployment <- function(port, env = Sys.getenv) {
97
97
  # loopback. So the posture is declarable in its own right and the bind
98
98
  # address merely IMPLIES it; an operator hardening a shared machine does not
99
99
  # have to move the socket to say so.
100
- require_origin <- identical(env("CARMAR_REQUIRE_ORIGIN", ""), "1") || !loopback
100
+ require_origin <- identical(env("BEATRINA_REQUIRE_ORIGIN", ""), "1") || !loopback
101
101
 
102
- trust_proxy <- identical(env("CARMAR_TRUST_PROXY", ""), "1")
103
- user_header <- trimws(env("CARMAR_USER_HEADER", "X-Forwarded-User"))
104
- proxy_addrs <- split_list(env("CARMAR_TRUSTED_PROXY", "127.0.0.1 ::1"))
105
- allow_open <- identical(env("CARMAR_ALLOW_UNAUTHENTICATED", ""), "1")
102
+ trust_proxy <- identical(env("BEATRINA_TRUST_PROXY", ""), "1")
103
+ user_header <- trimws(env("BEATRINA_USER_HEADER", "X-Forwarded-User"))
104
+ proxy_addrs <- split_list(env("BEATRINA_TRUSTED_PROXY", "127.0.0.1 ::1"))
105
+ allow_open <- identical(env("BEATRINA_ALLOW_UNAUTHENTICATED", ""), "1")
106
106
 
107
107
  errors <- character(0)
108
108
  if (!loopback) {
109
109
  if (!length(hosts_env)) {
110
110
  errors <- c(errors, paste0(
111
- "CARMAR_BIND is ", bind, " but CARMAR_HOSTS is unset. Off loopback the ",
111
+ "BEATRINA_BIND is ", bind, " but BEATRINA_HOSTS is unset. Off loopback the ",
112
112
  "Host allow-list is the only defence against DNS rebinding, so it must ",
113
- "name the hostname readers will use (e.g. CARMAR_HOSTS=stats.example.edu)."))
113
+ "name the hostname readers will use (e.g. BEATRINA_HOSTS=stats.example.edu)."))
114
114
  }
115
115
  if (!trust_proxy && !allow_open) {
116
116
  errors <- c(errors, paste0(
117
- "CARMAR_BIND is ", bind, " with no authentication in front of it. Put ",
118
- "CarmaR behind a reverse proxy that authenticates and set ",
119
- "CARMAR_TRUST_PROXY=1, or set CARMAR_ALLOW_UNAUTHENTICATED=1 to serve R ",
117
+ "BEATRINA_BIND is ", bind, " with no authentication in front of it. Put ",
118
+ "Beatrina behind a reverse proxy that authenticates and set ",
119
+ "BEATRINA_TRUST_PROXY=1, or set BEATRINA_ALLOW_UNAUTHENTICATED=1 to serve R ",
120
120
  "execution to everyone who can reach this port. See docs/server.md."))
121
121
  }
122
122
  if (trust_proxy && !nzchar(user_header)) {
123
- errors <- c(errors, "CARMAR_TRUST_PROXY=1 requires a non-empty CARMAR_USER_HEADER.")
123
+ errors <- c(errors, "BEATRINA_TRUST_PROXY=1 requires a non-empty BEATRINA_USER_HEADER.")
124
124
  }
125
125
  if (trust_proxy && !length(proxy_addrs)) {
126
- errors <- c(errors, "CARMAR_TRUST_PROXY=1 requires CARMAR_TRUSTED_PROXY to name the proxy's address.")
126
+ errors <- c(errors, "BEATRINA_TRUST_PROXY=1 requires BEATRINA_TRUSTED_PROXY to name the proxy's address.")
127
127
  }
128
128
  }
129
129
 
@@ -1,4 +1,4 @@
1
- # Test files shipped with CarmaR
1
+ # Test files shipped with Beatrina
2
2
 
3
3
  ## tna-complete-tutorial.Rmd
4
4
 
package/kernel/fileio.R CHANGED
@@ -27,7 +27,7 @@ fs_abs <- function(p, wd = getwd()) {
27
27
  }
28
28
 
29
29
  # ── the confinement root ────────────────────────────────────────────────────
30
- # CARMAR_ROOT restricts every FILE command — browse, read, write, import, and
30
+ # BEATRINA_ROOT restricts every FILE command — browse, read, write, import, and
31
31
  # changing the working directory — to one subtree. It exists for managed
32
32
  # deployments where a notebook should see the project share and nothing else.
33
33
  #
@@ -41,7 +41,7 @@ fs_abs <- function(p, wd = getwd()) {
41
41
  # the operating system's job: run the kernel as a user who cannot read the
42
42
  # rest, or in a container.
43
43
  confine_root <- local({
44
- raw <- Sys.getenv("CARMAR_ROOT", "")
44
+ raw <- Sys.getenv("BEATRINA_ROOT", "")
45
45
  if (!nzchar(raw)) NULL
46
46
  else normalizePath(path.expand(raw), mustWork = FALSE)
47
47
  })
@@ -414,7 +414,7 @@ fs_readfile <- function(id, path = NULL, wd = getwd()) {
414
414
  #' power-loss durability guarantee. The mode of an existing file is kept.
415
415
  #' Returns NULL on success or the sentence to report.
416
416
  fs_replace_atomic <- function(p, body_bytes) {
417
- temp <- tempfile(pattern = ".carmar-save-", tmpdir = dirname(p))
417
+ temp <- tempfile(pattern = ".beatrina-save-", tmpdir = dirname(p))
418
418
  existed <- file.exists(p)
419
419
  old_mode <- if (existed) file.info(p)$mode else NULL
420
420
  cleanup_save <- function() {
@@ -439,7 +439,7 @@ fs_replace_atomic <- function(p, body_bytes) {
439
439
  if (is.character(ok)) return(fail_after_cleanup(ok))
440
440
  # Keep the existing test knob as an alias for the pre-replacement boundary.
441
441
  # The original is still at its path; a failure needs no restoration rename.
442
- if (identical(Sys.getenv("CARMAR_TEST_SAVE_FAILURE", ""), "after-backup")) {
442
+ if (identical(Sys.getenv("BEATRINA_TEST_SAVE_FAILURE", ""), "after-backup")) {
443
443
  return(fail_after_cleanup("simulated failure before replacement"))
444
444
  }
445
445
  replaced <- tryCatch(suppressWarnings(file.rename(temp, p)), error = function(e) FALSE)
@@ -557,8 +557,8 @@ fs_writefiles_atomic <- function(id, files = NULL, wd = getwd()) {
557
557
  if (length(after_bytes) > MAX_TEXT_BYTES) return(fail(paste0(basename(p), " is too large")))
558
558
  plans[[length(plans) + 1L]] <- list(path = p, before = before, after = after, bytes = after_bytes,
559
559
  mode = file.info(p)$mode,
560
- temp = tempfile(pattern = ".carmar-new-", tmpdir = dirname(p)),
561
- backup = tempfile(pattern = ".carmar-old-", tmpdir = dirname(p)))
560
+ temp = tempfile(pattern = ".beatrina-new-", tmpdir = dirname(p)),
561
+ backup = tempfile(pattern = ".beatrina-old-", tmpdir = dirname(p)))
562
562
  }
563
563
 
564
564
  cleanup <- function() {
package/kernel/index.html CHANGED
@@ -1,6 +1,6 @@
1
1
  <!doctype html>
2
2
  <meta charset="utf-8">
3
- <title>CarmaR kernel spike</title>
3
+ <title>Beatrina kernel spike</title>
4
4
  <style>
5
5
  :root { color-scheme: light dark; }
6
6
  body { font: 14px/1.5 ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 1.5rem;
@@ -25,7 +25,7 @@
25
25
  img { display: block; margin: .5rem 0; border-radius: 3px; }
26
26
  </style>
27
27
 
28
- <h1>CarmaR kernel spike <span id="status">connecting…</span></h1>
28
+ <h1>Beatrina kernel spike <span id="status">connecting…</span></h1>
29
29
 
30
30
  <textarea id="src">for (i in 1:20) { cat("tick", i, "\n"); flush(stdout()); Sys.sleep(0.5) }</textarea>
31
31
 
package/kernel/job-run.R CHANGED
@@ -38,6 +38,7 @@ here <- dirname(normalizePath(sub("^--file=", "", grep("^--file=", commandArgs(F
38
38
  source(file.path(here, "kernel.R"))
39
39
  source(file.path(here, "jobs.R"))
40
40
  source(file.path(here, "latex.R"))
41
+ source(file.path(here, "typst.R"))
41
42
  compile_identity <- NULL
42
43
 
43
44
  #' Write one control frame: the session's random sentinel, then compact JSON.
@@ -59,12 +60,12 @@ emit <- function(obj) {
59
60
  invisible(NULL)
60
61
  }
61
62
 
62
- job_id <- Sys.getenv("CARMAR_JOB_ID", "job")
63
- task <- Sys.getenv("CARMAR_JOB_TASK", "")
64
- root <- Sys.getenv("CARMAR_JOB_ROOT", "")
65
- target <- Sys.getenv("CARMAR_JOB_TARGET", "")
66
- filter <- Sys.getenv("CARMAR_JOB_FILTER", "")
67
- format <- Sys.getenv("CARMAR_JOB_FORMAT", "")
63
+ job_id <- Sys.getenv("BEATRINA_JOB_ID", "job")
64
+ task <- Sys.getenv("BEATRINA_JOB_TASK", "")
65
+ root <- Sys.getenv("BEATRINA_JOB_ROOT", "")
66
+ target <- Sys.getenv("BEATRINA_JOB_TARGET", "")
67
+ filter <- Sys.getenv("BEATRINA_JOB_FILTER", "")
68
+ format <- Sys.getenv("BEATRINA_JOB_FORMAT", "")
68
69
 
69
70
  # The supervisor validated all of this in jobs.R before spawning. Re-checking
70
71
  # here is not redundant: this file is also runnable by hand, and a task that
@@ -82,18 +83,18 @@ if (!nzchar(root) || !dir.exists(root)) {
82
83
  }
83
84
  # A render acts on a FILE. Checking only the folder would let a task start
84
85
  # against a document that is not there and fail somewhere less legible.
85
- if (task %in% c("render", "latex") && (!nzchar(target) || !file.exists(target))) {
86
+ if (task %in% c("render", "latex", "typst") && (!nzchar(target) || !file.exists(target))) {
86
87
  emit(list(type = "job", event = "done", ok = FALSE,
87
88
  error = sprintf("'%s' is not a file", target)))
88
89
  quit(status = 2L, save = "no")
89
90
  }
90
91
  if (identical(task, "latex")) {
91
92
  prepared <- tryCatch({
92
- identity_text <- Sys.getenv("CARMAR_LATEX_IDENTITY", "")
93
+ identity_text <- Sys.getenv("BEATRINA_LATEX_IDENTITY", "")
93
94
  identity <- if (nzchar(identity_text)) jsonlite::fromJSON(identity_text, simplifyVector = FALSE) else NULL
94
- spec <- job_spec(task, target, list(engine = Sys.getenv("CARMAR_LATEX_ENGINE", "auto"),
95
- timeout = suppressWarnings(as.numeric(Sys.getenv("CARMAR_LATEX_TIMEOUT", as.character(LATEX_TIMEOUT)))),
96
- fetch = identical(Sys.getenv("CARMAR_LATEX_FETCH", ""), "1"), identity = identity))
95
+ spec <- job_spec(task, target, list(engine = Sys.getenv("BEATRINA_LATEX_ENGINE", "auto"),
96
+ timeout = suppressWarnings(as.numeric(Sys.getenv("BEATRINA_LATEX_TIMEOUT", as.character(LATEX_TIMEOUT)))),
97
+ fetch = identical(Sys.getenv("BEATRINA_LATEX_FETCH", ""), "1"), identity = identity))
97
98
  if (!isTRUE(spec$ok)) stop(spec$error, call. = FALSE)
98
99
  compile_identity <- identity
99
100
  spec
@@ -229,7 +230,7 @@ run_tests <- function() {
229
230
  #' Locate a quarto binary.
230
231
  #' @return Path to quarto, or "" when there is none.
231
232
  find_quarto <- function() {
232
- explicit <- Sys.getenv("CARMAR_QUARTO", "")
233
+ explicit <- Sys.getenv("BEATRINA_QUARTO", "")
233
234
  if (nzchar(explicit) && file.exists(explicit)) return(explicit)
234
235
  # RStudio and Positron both export this into the sessions they start.
235
236
  from_env <- Sys.getenv("QUARTO_PATH", "")
@@ -261,9 +262,11 @@ find_quarto <- function() {
261
262
  #'
262
263
  #' @param path The document.
263
264
  #' @return "quarto" or "rmarkdown".
264
- pick_renderer <- function(path) {
265
+ pick_renderer <- function(path, format = "") {
265
266
  ext <- tolower(sub("^.*\\.", "", basename(path)))
266
267
  if (identical(ext, "qmd")) return("quarto")
268
+ # rmarkdown has no Typst writer; Quarto renders .Rmd and .md too.
269
+ if (identical(format, "typst")) return("quarto")
267
270
  if (requireNamespace("rmarkdown", quietly = TRUE)) "rmarkdown" else "quarto"
268
271
  }
269
272
 
@@ -287,7 +290,7 @@ run_streaming <- function(bin, args, wd = NULL, timeout = Inf, env = NULL) {
287
290
  if (!nzchar(text)) return(invisible(NULL))
288
291
  cat(text, sep = ""); flush(stdout())
289
292
  kept <<- paste0(kept, text)
290
- if (nchar(kept) > 1000000L) { kept <<- substring(kept, nchar(kept) - 999999L); truncated <<- TRUE }
293
+ if (nchar(kept) > 1000000L) { kept <<- substring(kept, nchar(kept) - 999999L, nchar(kept)); truncated <<- TRUE }
291
294
  }
292
295
  repeat {
293
296
  proc$poll_io(100L)
@@ -316,26 +319,82 @@ run_streaming <- function(bin, args, wd = NULL, timeout = Inf, env = NULL) {
316
319
  #' puts the output beside the source and still reports it relatively. Either
317
320
  #' way the file is checked to exist before it is reported, for the same
318
321
  #' reason a test failure only offers a link to a file that is really there.
322
+ #' The folder a document declares for R: the flat front-matter line
323
+ #' `beatrina-workdir: data/` — the same line lib/wd-menu.js reads for the
324
+ #' interactive session, so a chunk run in the window and the same chunk in
325
+ #' a render start from ONE folder. Relative to the document's own folder;
326
+ #' absolute and `~` paths as written. The document's folder when absent.
327
+ #' Returns list(path, declared): `declared` is "" when nothing was said.
328
+ document_workdir <- function(target) {
329
+ base <- dirname(target)
330
+ lines <- tryCatch(readLines(target, n = 200L, warn = FALSE, encoding = "UTF-8"),
331
+ error = function(e) character())
332
+ if (!length(lines) || !identical(trimws(lines[[1L]]), "---")) return(list(path = base, declared = ""))
333
+ close <- which(trimws(lines[-1L]) %in% c("---", "..."))
334
+ if (!length(close)) return(list(path = base, declared = ""))
335
+ front <- lines[seq_len(close[[1L]])]
336
+ hit <- grep("^beatrina-workdir:", front, value = TRUE)
337
+ if (!length(hit)) return(list(path = base, declared = ""))
338
+ value <- trimws(sub("^beatrina-workdir:", "", hit[[1L]]))
339
+ value <- trimws(sub("[ \t]+#.*$", "", value))
340
+ value <- gsub("^[\"']|[\"']$", "", value)
341
+ if (!nzchar(value)) return(list(path = base, declared = ""))
342
+ path <- if (grepl("^(/|~|[A-Za-z]:[\\\\/]|\\\\\\\\)", value)) path.expand(value)
343
+ else normalizePath(file.path(base, value), mustWork = FALSE)
344
+ list(path = path, declared = value)
345
+ }
346
+
347
+ #' The name rmarkdown knows a format by. The job's vocabulary is the short one
348
+ #' Quarto speaks (`html`, `pdf`, `docx`, `latex`); rmarkdown::render() evaluates
349
+ #' a bare `html` as an R object and fails with "object 'html' not found" — so
350
+ #' until 2026-09-19 choosing a format for an .Rmd could not work at all.
351
+ #' @param format A RENDER_FORMATS entry; "" lets the document's YAML decide.
352
+ #' @return A format name, or NULL for the document's own.
353
+ rmarkdown_format <- function(format) {
354
+ if (!nzchar(format)) return(NULL)
355
+ switch(format,
356
+ html = "html_document", pdf = "pdf_document", docx = "word_document",
357
+ latex = "latex_document",
358
+ stop(sprintf("rmarkdown cannot render to '%s'", format), call. = FALSE))
359
+ }
360
+
319
361
  render_document <- function() {
320
- renderer <- pick_renderer(target)
362
+ renderer <- pick_renderer(target, format)
321
363
  produced <- NA_character_
364
+ # Where the code runs: the document's declared folder, else its own. Said
365
+ # when the declaration names a folder that is not there — a render that
366
+ # quietly ran somewhere else would make every relative path a mystery.
367
+ workdir <- document_workdir(target)
368
+ if (nzchar(workdir$declared) && !dir.exists(workdir$path)) {
369
+ stop(sprintf("the document declares beatrina-workdir: %s, but '%s' is not a folder",
370
+ workdir$declared, workdir$path), call. = FALSE)
371
+ }
322
372
 
323
373
  if (identical(renderer, "rmarkdown")) {
324
374
  need("rmarkdown")
325
375
  out <- rmarkdown::render(
326
376
  target,
327
- output_format = if (nzchar(format)) format else NULL,
328
- knit_root_dir = dirname(target)
377
+ output_format = rmarkdown_format(format),
378
+ knit_root_dir = workdir$path
329
379
  )
330
380
  if (length(out) >= 1L && nzchar(out[[1L]])) produced <- out[[1L]]
331
381
  } else {
332
382
  bin <- find_quarto()
333
383
  if (!nzchar(bin)) {
334
- stop("Quarto is not installed, or CarmaR could not find it. Install Quarto, ",
335
- "or set CARMAR_QUARTO to the binary.", call. = FALSE)
384
+ stop("Quarto is not installed, or Beatrina could not find it. Install Quarto, ",
385
+ "or set BEATRINA_QUARTO to the binary.", call. = FALSE)
336
386
  }
337
387
  args <- c("render", target)
338
388
  if (nzchar(format)) args <- c(args, "--to", format)
389
+ # Typst figures default to SVG, which knitr draws through cairo — and a
390
+ # Mac without XQuartz has no cairo, so the render failed at the first plot
391
+ # (measured, 2026-09-19). PNG needs nothing; keep-typ leaves the .typ
392
+ # source beside the PDF, which is what someone editing the paper wants.
393
+ if (identical(format, "typst")) args <- c(args, "-M", "fig-format:png", "-M", "keep-typ:true")
394
+ # Quarto's own execution directory, only when the document asked: its
395
+ # default (the document's folder, or the project's when the project says
396
+ # so) stays Quarto's decision for a document that said nothing.
397
+ if (nzchar(workdir$declared)) args <- c(args, "--execute-dir", workdir$path)
339
398
  result <- run_streaming(bin, args)
340
399
  if (!identical(as.integer(result$status), 0L)) {
341
400
  stop(sprintf("quarto render exited %s", result$status), call. = FALSE)
@@ -374,9 +433,11 @@ outcome <- tryCatch({
374
433
  build = { need("devtools"); devtools::build(root); NULL },
375
434
  install = { need("devtools"); devtools::install(root, quick = TRUE, upgrade = "never"); NULL },
376
435
  render = render_document(),
377
- latex = latex_compile(target, prepared$env[["CARMAR_LATEX_ENGINE"]],
378
- as.numeric(prepared$env[["CARMAR_LATEX_TIMEOUT"]]), job_id, run_streaming, emit,
379
- fetch = identical(prepared$env[["CARMAR_LATEX_FETCH"]], "1"))
436
+ typst = typst_compile(target, suppressWarnings(as.numeric(Sys.getenv("BEATRINA_TYPST_TIMEOUT", as.character(TYPST_TIMEOUT)))),
437
+ run_streaming, emit, pages = identical(Sys.getenv("BEATRINA_TYPST_PAGES", ""), "svg")),
438
+ latex = latex_compile(target, prepared$env[["BEATRINA_LATEX_ENGINE"]],
439
+ as.numeric(prepared$env[["BEATRINA_LATEX_TIMEOUT"]]), job_id, run_streaming, emit,
440
+ fetch = identical(prepared$env[["BEATRINA_LATEX_FETCH"]], "1"))
380
441
  )
381
442
  list(ok = TRUE, error = "")
382
443
  }, error = function(e) {
package/kernel/jobs.R CHANGED
@@ -125,7 +125,10 @@ JOB_TASKS <- list(
125
125
  build = list(label = "Build", running = "Building", target = "package", pkg = "devtools"),
126
126
  install = list(label = "Install", running = "Installing", target = "package", pkg = "devtools"),
127
127
  render = list(label = "Render", running = "Rendering", target = "document", pkg = NA_character_),
128
- latex = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_)
128
+ latex = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_),
129
+ # Typst: one document in, one PDF out — the same shape as latex, a different
130
+ # compiler (Quarto bundles it; nothing to install).
131
+ typst = list(label = "Compile", running = "Compiling", target = "document", pkg = NA_character_)
129
132
  )
130
133
 
131
134
  #' Documents this kernel will render. Lower-cased extension, no dot.
@@ -140,7 +143,7 @@ RENDER_EXTENSIONS <- c("qmd", "rmd", "md")
140
143
  # decides. A document whose header says `format: revealjs` must render as
141
144
  # revealjs when the reader presses Render without choosing anything —
142
145
  # defaulting to html would silently overrule the file.
143
- RENDER_FORMATS <- c("", "html", "pdf", "docx")
146
+ RENDER_FORMATS <- c("", "html", "pdf", "docx", "latex", "typst")
144
147
 
145
148
  #' Is this a single, plain, non-empty string? Mirrors serve.R's scalar_chr —
146
149
  #' R's coercion rules are exactly permissive enough to turn a JSON array into
@@ -167,7 +170,7 @@ path_extension <- function(path) {
167
170
  #' Decide whether one task may run, and exactly what would run.
168
171
  #'
169
172
  #' The whole decision in one pure function, returning it whole — the same
170
- #' shape as `carmar_deployment()`, and for the same reason: three callers
173
+ #' shape as `beatrina_deployment()`, and for the same reason: three callers
171
174
  #' (the supervisor, the tests, the UI's error message) must not each
172
175
  #' re-derive it and drift.
173
176
  #'
@@ -178,7 +181,7 @@ path_extension <- function(path) {
178
181
  #' @param task Task name from the wire.
179
182
  #' @param target Directory or file from the wire.
180
183
  #' @param opts Named list of extras from the wire: `filter` (test), `format`
181
- #' (render). Unknown members are ignored rather than refused — a page one
184
+ #' (render), `pages` (typst). Unknown members are ignored rather than refused — a page one
182
185
  #' version ahead must not be an error.
183
186
  #' @return list(ok, error, task, target, root, label, package, env) — `env` is
184
187
  #' the named character vector of environment variables for the child, and is
@@ -214,9 +217,9 @@ job_spec <- function(task, target, opts = list()) {
214
217
  }
215
218
  target <- tryCatch(normalizePath(target, mustWork = TRUE), error = function(e) target)
216
219
  ext <- path_extension(target)
217
- extensions <- if (identical(task, "latex")) "tex" else RENDER_EXTENSIONS
220
+ extensions <- if (identical(task, "latex")) "tex" else if (identical(task, "typst")) "typ" else RENDER_EXTENSIONS
218
221
  if (!ext %in% extensions) {
219
- return(bad(sprintf("CarmaR renders %s files; '%s' is a .%s",
222
+ return(bad(sprintf("Beatrina renders %s files; '%s' is a .%s",
220
223
  paste0(".", extensions, collapse = ", "), basename(target),
221
224
  if (nzchar(ext)) ext else "(no extension)")))
222
225
  }
@@ -234,6 +237,18 @@ job_spec <- function(task, target, opts = list()) {
234
237
  if (nzchar(format) && !identical(task, "render")) {
235
238
  return(bad("a format only applies to the render task"))
236
239
  }
240
+ # `pages` asks Typst for one SVG per page instead of a PDF: what the live
241
+ # preview draws, so it can keep the reader's place across a rebuild
242
+ # (lib/doc-preview.js). Strict like `fetch` — exactly TRUE, never arrived at
243
+ # by coercion — and it means nothing to any other task.
244
+ pages <- isTRUE(opts$pages)
245
+ if (pages && !identical(task, "typst")) return(bad("pages only apply to the typst task"))
246
+ # …and only INSIDE the preview's own hidden folder: a pages compile deletes the
247
+ # last compile's `<stem>-N.svg` first, and that must never be aimed at a folder
248
+ # of somebody's own files. (The twin of host/planes/jobs.mjs.)
249
+ if (pages && !identical(basename(dirname(target)), ".beatrina-preview")) {
250
+ return(bad("pages are compiled only in a document's .beatrina-preview folder"))
251
+ }
237
252
  if (!format %in% RENDER_FORMATS) {
238
253
  return(bad(sprintf("'%s' is not a format this kernel renders (%s)",
239
254
  format, paste(setdiff(RENDER_FORMATS, ""), collapse = ", "))))
@@ -245,9 +260,10 @@ job_spec <- function(task, target, opts = list()) {
245
260
  label = spec$label, running = spec$running, targets = spec$target,
246
261
  package = if (identical(spec$target, "package")) package_name(root) else "",
247
262
  needs = spec$pkg,
248
- env = c(CARMAR_JOB_TASK = task, CARMAR_JOB_TARGET = target,
249
- CARMAR_JOB_ROOT = root, CARMAR_JOB_FILTER = filter,
250
- CARMAR_JOB_FORMAT = format, latex_env)
263
+ env = c(BEATRINA_JOB_TASK = task, BEATRINA_JOB_TARGET = target,
264
+ BEATRINA_JOB_ROOT = root, BEATRINA_JOB_FILTER = filter,
265
+ BEATRINA_JOB_FORMAT = format,
266
+ BEATRINA_TYPST_PAGES = if (pages) "svg" else "", latex_env)
251
267
  )
252
268
  }
253
269
 
@@ -260,14 +276,14 @@ job_spec <- function(task, target, opts = list()) {
260
276
  #' @param spec A successful `job_spec()`.
261
277
  #' @return One line of text.
262
278
  job_title <- function(spec) {
263
- if (identical(spec$task, "latex")) return(sprintf("Compiling %s", basename(spec$target)))
279
+ if (identical(spec$task, "latex") || identical(spec$task, "typst")) return(sprintf("Compiling %s", basename(spec$target)))
264
280
  if (identical(spec$targets, "document")) {
265
- format <- spec$env[["CARMAR_JOB_FORMAT"]]
281
+ format <- spec$env[["BEATRINA_JOB_FORMAT"]]
266
282
  return(sprintf("Rendering %s%s", basename(spec$target),
267
283
  if (nzchar(format)) paste0(" to ", format) else ""))
268
284
  }
269
285
  name <- if (nzchar(spec$package)) spec$package else basename(spec$root)
270
- filter <- spec$env[["CARMAR_JOB_FILTER"]]
286
+ filter <- spec$env[["BEATRINA_JOB_FILTER"]]
271
287
  if (identical(spec$task, "test") && nzchar(filter)) {
272
288
  sprintf("%s %s — tests matching '%s'", spec$running, name, filter)
273
289
  } else {
@@ -1 +1 @@
1
- 0.8.7
1
+ 0.9.43