beatrina 0.8.6 → 0.8.7
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.
- package/bin/beatrina.mjs +7 -5
- package/bin/shortcut.mjs +34 -6
- package/build-info.json +1 -1
- package/carmar_V0.8.7.html +1522 -0
- package/failsafe/serve.R +130 -5
- package/failsafe/session-documents.R +104 -0
- package/host/engine-js.mjs +4 -0
- package/host/engine-pool.mjs +8 -2
- package/host/engine-r.mjs +20 -13
- package/host/engine-stdio.mjs +10 -2
- package/host/main.mjs +28 -4
- package/host/planes/latex.mjs +27 -1
- package/host/planes/sessions.mjs +9 -5
- package/host/server.mjs +49 -4
- package/host/session-keep.mjs +70 -0
- package/host/worker-plane.mjs +47 -2
- package/kernel/kernel-version +1 -1
- package/kernel/kernel.R +8 -5
- package/kernel/latex.R +305 -4
- package/kernel/worker.R +85 -9
- package/kernel/workspace-keep.R +188 -0
- package/package.json +2 -2
- package/carmar_V0.8.6.html +0 -1310
package/failsafe/serve.R
CHANGED
|
@@ -56,6 +56,11 @@ source(file.path(here, "settings.R"))
|
|
|
56
56
|
source(file.path(here, "fileio.R"))
|
|
57
57
|
source(file.path(here, "journal.R"))
|
|
58
58
|
source(file.path(here, "ai-store.R"))
|
|
59
|
+
# "Restart, keep variables": the supervisor never reads a kept workspace; it
|
|
60
|
+
# only checks the token (keep_token_ok) and carries it to the next worker.
|
|
61
|
+
source(file.path(here, "workspace-keep.R"))
|
|
62
|
+
# Which documents this session has open — the session's record, not the page's.
|
|
63
|
+
source(file.path(here, "session-documents.R"))
|
|
59
64
|
|
|
60
65
|
# Apply an administrator-owned desktop policy before any subsystem reads its
|
|
61
66
|
# environment. Invalid or user-writable policy fails closed at the shared
|
|
@@ -319,6 +324,17 @@ sockets$open <- list()
|
|
|
319
324
|
# connects. Without replaying it, every page that opens later sits on
|
|
320
325
|
# "connecting…" forever waiting for a frame that was broadcast to nobody.
|
|
321
326
|
sockets$hello <- NULL
|
|
327
|
+
# A kept workspace for the NEXT ready worker: set by `restart {resume}` or, in a
|
|
328
|
+
# handoff successor, by CARMAR_RESUME_TOKEN. Consumed once, on `ready`.
|
|
329
|
+
sockets$resume_pending <- if (keep_token_ok(Sys.getenv("CARMAR_RESUME_TOKEN")))
|
|
330
|
+
Sys.getenv("CARMAR_RESUME_TOKEN") else NULL
|
|
331
|
+
# The restore's report, kept for 120 s so a page that follows a handoff — and
|
|
332
|
+
# connects after the restore finished — still hears what came back.
|
|
333
|
+
sockets$resume_report <- NULL
|
|
334
|
+
# The documents open in this session (spike/session-documents.R), as the pages
|
|
335
|
+
# last reported them. A handoff successor starts with its predecessor's list.
|
|
336
|
+
sockets$session_documents <- session_documents_decode(Sys.getenv("CARMAR_SESSION_DOCUMENTS"))
|
|
337
|
+
Sys.unsetenv("CARMAR_SESSION_DOCUMENTS")
|
|
322
338
|
sockets$worker_recovering <- FALSE
|
|
323
339
|
sockets$worker_restart_attempts <- 0L
|
|
324
340
|
sockets$worker_restart_after <- 0
|
|
@@ -1078,6 +1094,8 @@ start_sibling_session <- function() {
|
|
|
1078
1094
|
# Ops a declared MCP agent is refused outright: raw evaluation (it must go
|
|
1079
1095
|
# through a visible chunk_run) and the user's credentials.
|
|
1080
1096
|
AGENT_REFUSED <- c("exec", "interrupt", "force_stop", "restart", "session_upgrade", "session_restart", "debug_cmd", "ai-key",
|
|
1097
|
+
# A copy of every object in the session, written to disk.
|
|
1098
|
+
"suspend", "suspend_cancel",
|
|
1081
1099
|
# The user's AI conversations. An agent reading them would be
|
|
1082
1100
|
# reading every question the user has asked about their data,
|
|
1083
1101
|
# including the ones they asked about a different agent.
|
|
@@ -1383,6 +1401,11 @@ FORWARDED <- c("env", "obj", "struct", "view", "colstats", "rm", "packages", "do
|
|
|
1383
1401
|
"package_action", "package_help", "project_status", "project_action", "help", "wd",
|
|
1384
1402
|
"parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
|
|
1385
1403
|
"hover", "format", "sniff",
|
|
1404
|
+
# "Restart, keep variables", step one (spike/workspace-keep.R).
|
|
1405
|
+
# Page-only in both senses: AGENT_REFUSED above, the class gate
|
|
1406
|
+
# in handle_frame. Step two, `resume`, is deliberately NOT here —
|
|
1407
|
+
# only the supervisor asks it, after a restart it performed.
|
|
1408
|
+
"suspend",
|
|
1386
1409
|
# The file tree's New Folder, Rename and Delete. These MUTATE
|
|
1387
1410
|
# the filesystem, which is a wider door than the read-only ops
|
|
1388
1411
|
# around them, so the bar they clear is stated rather than
|
|
@@ -1865,6 +1888,10 @@ app <- list(
|
|
|
1865
1888
|
audit("socket-open", sockets = length(sockets$open), class = rec$class,
|
|
1866
1889
|
user = rec$user)
|
|
1867
1890
|
if (!is.null(sockets$hello)) try(ws$send(sockets$hello), silent = TRUE)
|
|
1891
|
+
if (!is.null(sockets$resume_report) && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)
|
|
1892
|
+
&& as.numeric(difftime(Sys.time(), sockets$resume_report$at, units = "secs")) < 120) {
|
|
1893
|
+
try(ws$send(sockets$resume_report$payload), silent = TRUE)
|
|
1894
|
+
}
|
|
1868
1895
|
# …and the give-up, when there is one. A page that arrives after the
|
|
1869
1896
|
# respawn ceiling has no ready frame coming and would otherwise sit on
|
|
1870
1897
|
# "connecting…" forever with nothing on screen saying why.
|
|
@@ -3166,9 +3193,11 @@ handoff_page_url <- function(installed, cap) {
|
|
|
3166
3193
|
#' only ever mean "spawned, exiting".
|
|
3167
3194
|
#' @param mode "upgrade" or "restart".
|
|
3168
3195
|
#' @param force Kill a busy worker rather than refuse (restart only).
|
|
3196
|
+
#' @param resume A kept-workspace token (spike/workspace-keep.R) the successor's
|
|
3197
|
+
#' first worker restores, or NULL for a fresh R.
|
|
3169
3198
|
#' @return The reply the caller sends: ok/from/to/page/started/mode/force,
|
|
3170
3199
|
#' or ok = FALSE with error and reason.
|
|
3171
|
-
session_handoff_begin <- function(mode = "upgrade", force = FALSE) {
|
|
3200
|
+
session_handoff_begin <- function(mode = "upgrade", force = FALSE, resume = NULL) {
|
|
3172
3201
|
mode <- if (identical(mode, "restart")) "restart" else "upgrade"
|
|
3173
3202
|
force <- isTRUE(force) && identical(mode, "restart")
|
|
3174
3203
|
audit_name <- if (identical(mode, "restart")) "session-restart" else "session-upgrade"
|
|
@@ -3181,7 +3210,8 @@ session_handoff_begin <- function(mode = "upgrade", force = FALSE) {
|
|
|
3181
3210
|
cap <- secure_token(32L)
|
|
3182
3211
|
page <- handoff_page_url(installed, cap)
|
|
3183
3212
|
sockets$handoff <- list(to = installed, cap = cap, page = page,
|
|
3184
|
-
started = Sys.time(), mode = mode, force = force
|
|
3213
|
+
started = Sys.time(), mode = mode, force = force,
|
|
3214
|
+
resume = if (keep_token_ok(resume)) resume else NULL)
|
|
3185
3215
|
audit(audit_name, from = CARMAR_KERNEL_BUILD, to = installed, force = force)
|
|
3186
3216
|
# Every page learns where its successor's notebook is — over the gated
|
|
3187
3217
|
# socket only; the capability never appears in /health. `started` is this
|
|
@@ -3189,7 +3219,8 @@ session_handoff_begin <- function(mode = "upgrade", force = FALSE) {
|
|
|
3189
3219
|
# which is the only test that also works when the build does not change.
|
|
3190
3220
|
told <- toJSON(list(type = "session-upgrade", from = CARMAR_KERNEL_BUILD,
|
|
3191
3221
|
to = installed, page = page, started = as.numeric(sockets$boot_at),
|
|
3192
|
-
mode = mode, force = force
|
|
3222
|
+
mode = mode, force = force,
|
|
3223
|
+
keep = !is.null(sockets$handoff$resume)), auto_unbox = TRUE)
|
|
3193
3224
|
lapply(page_recs(), function(r) try(r$ws$send(told), silent = TRUE))
|
|
3194
3225
|
session_handoff_spawn()
|
|
3195
3226
|
list(ok = TRUE, from = CARMAR_KERNEL_BUILD, to = installed, page = page,
|
|
@@ -3224,6 +3255,9 @@ session_handoff_spawn <- function() {
|
|
|
3224
3255
|
env[["CARMAR_FILE_LAUNCH_CAP"]] <- h$cap
|
|
3225
3256
|
env[["CARMAR_HANDOFF_FROM"]] <- CARMAR_KERNEL_BUILD
|
|
3226
3257
|
env[["CARMAR_SESSION_TITLE"]] <- runtime_record$title %||% ""
|
|
3258
|
+
env[["CARMAR_SESSION_DOCUMENTS"]] <- session_documents_encode(sockets$session_documents)
|
|
3259
|
+
# Only a token travels, never a path; the successor re-checks it.
|
|
3260
|
+
env[["CARMAR_RESUME_TOKEN"]] <- h$resume %||% ""
|
|
3227
3261
|
if (isTRUE(runtime_record$listen)) env[["CARMAR_LISTEN"]] <- "1"
|
|
3228
3262
|
# The same R this supervisor runs on, and the serve.R beside THIS file —
|
|
3229
3263
|
# which, after the in-place swap, is the installed build's.
|
|
@@ -3480,6 +3514,32 @@ route_internal <- function(cmd, on_reply) {
|
|
|
3480
3514
|
list(cmd = cmd, wire_id = wire_id)
|
|
3481
3515
|
}
|
|
3482
3516
|
|
|
3517
|
+
#' Ask the fresh worker to restore a kept workspace, and tell every page what
|
|
3518
|
+
#' came back. Queued like any command, so a chunk run pressed meanwhile waits
|
|
3519
|
+
#' for the restore rather than racing it.
|
|
3520
|
+
#' @return Invisibly NULL.
|
|
3521
|
+
start_resume <- function() {
|
|
3522
|
+
token <- sockets$resume_pending
|
|
3523
|
+
sockets$resume_pending <- NULL
|
|
3524
|
+
if (!keep_token_ok(token)) return(invisible(NULL))
|
|
3525
|
+
audit("resume-start")
|
|
3526
|
+
routed <- route_internal(list(type = "resume", token = token), on_reply = function(frame) {
|
|
3527
|
+
if (scalar_chr(frame$cwd)) sockets$worker_wd <- frame$cwd
|
|
3528
|
+
report <- list(type = "workspace_restored",
|
|
3529
|
+
restored = frame$restored %||% 0L,
|
|
3530
|
+
failed = frame$failed %||% list(), skipped = frame$skipped %||% list(),
|
|
3531
|
+
wd_missing = frame$wd_missing %||% NULL, error = frame$error %||% NULL)
|
|
3532
|
+
payload <- toJSON(report, auto_unbox = TRUE, null = "null")
|
|
3533
|
+
sockets$resume_report <- list(at = Sys.time(), payload = payload)
|
|
3534
|
+
audit("resume-done", restored = report$restored, failed = length(report$failed),
|
|
3535
|
+
error = !is.null(report$error))
|
|
3536
|
+
pages <- Filter(function(r) isTRUE(r$class %in% PAGE_ONLY_CLASSES), page_recs())
|
|
3537
|
+
lapply(pages, function(r) try(r$ws$send(payload), silent = TRUE))
|
|
3538
|
+
})
|
|
3539
|
+
enqueue_worker_command(routed$cmd, routed$wire_id)
|
|
3540
|
+
invisible(NULL)
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3483
3543
|
#' Tell every page, and REMEMBER it for the pages that are not here yet.
|
|
3484
3544
|
#'
|
|
3485
3545
|
#' `sockets$hello` exists because the worker announces itself once, long
|
|
@@ -3670,6 +3730,34 @@ handle_frame <- function(message, rec) {
|
|
|
3670
3730
|
return(invisible(NULL))
|
|
3671
3731
|
}
|
|
3672
3732
|
|
|
3733
|
+
# The documents this session has open (spike/session-documents.R). Page-only
|
|
3734
|
+
# in BOTH senses: the list is file paths, and a page that could not open a
|
|
3735
|
+
# terminal must not be able to rewrite what the session reopens either.
|
|
3736
|
+
# Replies nothing; the helper's label follows through the runtime record.
|
|
3737
|
+
if (identical(cmd$type, "page-documents")) {
|
|
3738
|
+
if (identical(rec$role, "page") && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
3739
|
+
sockets$session_documents <- session_documents_clean(cmd$documents)
|
|
3740
|
+
set_runtime_documents(sockets$session_documents)
|
|
3741
|
+
}
|
|
3742
|
+
return(invisible(NULL))
|
|
3743
|
+
}
|
|
3744
|
+
|
|
3745
|
+
# A page asks what this session had open, to reopen it. `others` counts the
|
|
3746
|
+
# OTHER pages attached: a page joining a session another window is showing
|
|
3747
|
+
# must not open the same documents a second time (that window holds their
|
|
3748
|
+
# history leases), so the page reopens only when it is alone.
|
|
3749
|
+
if (identical(cmd$type, "session-documents")) {
|
|
3750
|
+
if (!scalar_chr(cmd$id)) return(invisible(NULL))
|
|
3751
|
+
allowed <- identical(rec$role, "page") && isTRUE(rec$class %in% PAGE_ONLY_CLASSES)
|
|
3752
|
+
others <- Filter(function(r) !identical(r$ws, rec$ws), page_recs())
|
|
3753
|
+
try(rec$ws$send(toJSON(list(
|
|
3754
|
+
type = "session-documents", id = cmd$id,
|
|
3755
|
+
documents = if (allowed) sockets$session_documents else list(),
|
|
3756
|
+
others = length(others),
|
|
3757
|
+
refused = !allowed), auto_unbox = TRUE)), silent = TRUE)
|
|
3758
|
+
return(invisible(NULL))
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3673
3761
|
# ── the MCP plane ─────────────────────────────────────────────────────────
|
|
3674
3762
|
# A local agent (Claude Code / Codex via tools/mcp/carmar-mcp.mjs) connects
|
|
3675
3763
|
# through the same loopback/Origin/Host gates as a page, then DECLARES itself.
|
|
@@ -3791,7 +3879,8 @@ handle_frame <- function(message, rec) {
|
|
|
3791
3879
|
return(invisible(NULL))
|
|
3792
3880
|
}
|
|
3793
3881
|
reply(session_handoff_begin(mode = if (identical(cmd$type, "session_restart")) "restart" else "upgrade",
|
|
3794
|
-
force = isTRUE(cmd$force)
|
|
3882
|
+
force = isTRUE(cmd$force),
|
|
3883
|
+
resume = if (keep_token_ok(cmd$resume)) cmd$resume else NULL))
|
|
3795
3884
|
return(invisible(NULL))
|
|
3796
3885
|
}
|
|
3797
3886
|
# ── terminals ─────────────────────────────────────────────────────────────
|
|
@@ -4163,6 +4252,25 @@ handle_frame <- function(message, rec) {
|
|
|
4163
4252
|
return(invisible(NULL))
|
|
4164
4253
|
}
|
|
4165
4254
|
|
|
4255
|
+
# "Restart, keep variables": the save is a copy of the whole session, so it
|
|
4256
|
+
# is page-only in BOTH senses, and its cancel interrupts only a save THIS
|
|
4257
|
+
# page asked for — Interrupt's own gate matches exec routes, never this.
|
|
4258
|
+
if (cmd$type %in% c("suspend", "suspend_cancel") && !isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
4259
|
+
if (scalar_chr(cmd$id)) try(rec$ws$send(toJSON(list(
|
|
4260
|
+
type = cmd$type, id = cmd$id, error = "Only the local notebook page may keep the workspace."
|
|
4261
|
+
), auto_unbox = TRUE)), silent = TRUE)
|
|
4262
|
+
audit("suspend-refused", reason = "class", class = rec$class %||% "unknown")
|
|
4263
|
+
return(invisible(NULL))
|
|
4264
|
+
}
|
|
4265
|
+
if (identical(cmd$type, "suspend_cancel")) {
|
|
4266
|
+
active <- sockets$worker_active
|
|
4267
|
+
route <- if (!is.null(active)) sockets$worker_routes[[active]] else NULL
|
|
4268
|
+
if (identical(sockets$worker_active_type, "suspend") && !is.null(route) && identical(route$rec, rec)) {
|
|
4269
|
+
audit("suspend-cancel")
|
|
4270
|
+
kernel_interrupt(k)
|
|
4271
|
+
}
|
|
4272
|
+
return(invisible(NULL))
|
|
4273
|
+
}
|
|
4166
4274
|
if (identical(cmd$type, "project_action") && !isTRUE(rec$class %in% PAGE_ONLY_CLASSES)) {
|
|
4167
4275
|
if (scalar_chr(cmd$id)) try(rec$ws$send(toJSON(list(
|
|
4168
4276
|
type = "project_action", id = cmd$id, ok = FALSE, reason = "class",
|
|
@@ -4339,11 +4447,12 @@ handle_frame <- function(message, rec) {
|
|
|
4339
4447
|
# hello is stale the moment the old worker dies; the new worker's ready frame
|
|
4340
4448
|
# replaces it via pump() and reaches every open socket.
|
|
4341
4449
|
if (identical(cmd$type, "restart")) {
|
|
4342
|
-
audit("restart")
|
|
4450
|
+
audit("restart", keep = keep_token_ok(cmd$resume))
|
|
4343
4451
|
fail_worker_routes("R was restarted — this request was abandoned.")
|
|
4344
4452
|
try(kernel_stop(k, grace = 1), silent = TRUE)
|
|
4345
4453
|
k <<- start_execution_worker()
|
|
4346
4454
|
sockets$hello <- NULL
|
|
4455
|
+
sockets$resume_pending <- if (keep_token_ok(cmd$resume)) cmd$resume else NULL
|
|
4347
4456
|
sockets$worker_recovering <- FALSE
|
|
4348
4457
|
sockets$worker_restart_attempts <- 0L
|
|
4349
4458
|
# An explicit Restart R is the ONE door out of a give-up: the user has
|
|
@@ -5167,6 +5276,7 @@ pump <- function() {
|
|
|
5167
5276
|
payload <- relay_frame(e)
|
|
5168
5277
|
if (identical(e$type, "ready")) sockets$hello <- payload
|
|
5169
5278
|
lapply(sockets$open, function(r) try(r$ws$send(payload), silent = TRUE))
|
|
5279
|
+
if (identical(e$type, "ready") && !is.null(sockets$resume_pending)) start_resume()
|
|
5170
5280
|
invisible(NULL)
|
|
5171
5281
|
})
|
|
5172
5282
|
invisible(NULL)
|
|
@@ -5236,6 +5346,8 @@ local({
|
|
|
5236
5346
|
Sys.unsetenv("CARMAR_SESSION_TITLE")
|
|
5237
5347
|
title <- trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", title)), 1L, 120L))
|
|
5238
5348
|
if (nzchar(title)) runtime_record$title <<- title
|
|
5349
|
+
label <- session_documents_label(sockets$session_documents)
|
|
5350
|
+
if (nzchar(label)) runtime_record$documents <<- label
|
|
5239
5351
|
})
|
|
5240
5352
|
# CARMAR_LISTEN=1 marks a kernel started headless for published pages (the
|
|
5241
5353
|
# menu's Listen for Web Pages, the keep-ready daemon). The menu helper shows
|
|
@@ -5280,6 +5392,19 @@ set_runtime_title <- function(title) {
|
|
|
5280
5392
|
write_runtime()
|
|
5281
5393
|
}
|
|
5282
5394
|
|
|
5395
|
+
#' The session's documents, as a label, into the runtime record.
|
|
5396
|
+
#'
|
|
5397
|
+
#' `documents` is what the menu helper shows in place of the notebook's
|
|
5398
|
+
#' generated name ("SezerProfiles.qmd", not "Mellow Yarrow 7Z6"). A flat
|
|
5399
|
+
#' string on purpose — helper-sessions.sh reads the record with a bounded sed.
|
|
5400
|
+
set_runtime_documents <- function(documents) {
|
|
5401
|
+
label <- session_documents_label(documents)
|
|
5402
|
+
if (identical(runtime_record$documents %||% "", label)) return(invisible(NULL))
|
|
5403
|
+
if (nzchar(label)) runtime_record$documents <<- label
|
|
5404
|
+
else runtime_record$documents <<- NULL
|
|
5405
|
+
write_runtime()
|
|
5406
|
+
}
|
|
5407
|
+
|
|
5283
5408
|
#' A page attached: this kernel is now a session, not a bare listener.
|
|
5284
5409
|
clear_runtime_listen <- function() {
|
|
5285
5410
|
if (is.null(runtime_record$listen)) return(invisible(NULL))
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# session-documents.R — which documents a session has open, held by the kernel.
|
|
2
|
+
#
|
|
3
|
+
# Why the kernel, and not the page's storage: every session of one build is
|
|
4
|
+
# served by the SAME notebook file, and the page remembers its open documents
|
|
5
|
+
# per file (lib/open-documents.js). So two sessions share one record, and the
|
|
6
|
+
# later one overwrites the earlier — reopening a session from the menu helper
|
|
7
|
+
# brought back its R (40 objects, 1.9 GB) in front of an empty notebook
|
|
8
|
+
# (owner, 2026-09-14: "it has the objects but not the file").
|
|
9
|
+
#
|
|
10
|
+
# The session is the thing that has an identity here, so the session keeps the
|
|
11
|
+
# list. The page reports it (`page-documents`); a page that attaches to a
|
|
12
|
+
# running session with nobody else on it asks for it back (`session-documents`)
|
|
13
|
+
# and reopens each file. Only paths, names and formats travel — never text:
|
|
14
|
+
# unsaved edits stay in the page that made them, and a document that was never
|
|
15
|
+
# saved is listed by name so the page can SAY it cannot come back.
|
|
16
|
+
#
|
|
17
|
+
# Pure functions; spike/serve.R holds the state. Pinned by
|
|
18
|
+
# spike/test-session-documents.R.
|
|
19
|
+
|
|
20
|
+
SESSION_DOCUMENTS_MAX <- 64L
|
|
21
|
+
SESSION_DOCUMENT_FORMATS <- c("qmd", "Rmd", "md", "carmd")
|
|
22
|
+
|
|
23
|
+
#' Validate a page's report into a clean list of documents.
|
|
24
|
+
#'
|
|
25
|
+
#' Anything malformed is dropped row by row, never the whole report: one odd
|
|
26
|
+
#' row must not cost the session the record of the others.
|
|
27
|
+
#'
|
|
28
|
+
#' @param documents What jsonlite made of the frame's `documents` field
|
|
29
|
+
#' (simplifyVector = TRUE: a data.frame, a list, or NULL).
|
|
30
|
+
#' @return A list of `list(path, name, format, active)`; `path` is "" for a
|
|
31
|
+
#' document that has no file yet. At most SESSION_DOCUMENTS_MAX rows.
|
|
32
|
+
session_documents_clean <- function(documents) {
|
|
33
|
+
if (is.null(documents) || !length(documents)) return(list())
|
|
34
|
+
rows <- if (is.data.frame(documents)) {
|
|
35
|
+
lapply(seq_len(nrow(documents)), \(i) as.list(documents[i, , drop = FALSE]))
|
|
36
|
+
} else if (is.list(documents)) {
|
|
37
|
+
documents
|
|
38
|
+
} else {
|
|
39
|
+
return(list())
|
|
40
|
+
}
|
|
41
|
+
one <- function(row) {
|
|
42
|
+
if (!is.list(row)) return(NULL)
|
|
43
|
+
text_of <- function(value, cap) {
|
|
44
|
+
if (!is.character(value) || length(value) != 1L || is.na(value)) return(NULL)
|
|
45
|
+
value <- gsub("[[:cntrl:]]", " ", value)
|
|
46
|
+
if (nchar(value) > cap) return(NULL)
|
|
47
|
+
value
|
|
48
|
+
}
|
|
49
|
+
path <- text_of(row$path %||% "", 4096L)
|
|
50
|
+
name <- text_of(row$name %||% "", 200L)
|
|
51
|
+
format <- text_of(row$format %||% "", 16L)
|
|
52
|
+
if (is.null(path) || is.null(name) || is.null(format)) return(NULL)
|
|
53
|
+
# A path is reopened through the file ops, which resolve it themselves;
|
|
54
|
+
# here it need only be absolute, so a relative string cannot be read
|
|
55
|
+
# against whatever the worker's cwd is by then.
|
|
56
|
+
if (nzchar(path) && !grepl("^(/|[A-Za-z]:[/\\\\])", path)) return(NULL)
|
|
57
|
+
if (!nzchar(path) && !nzchar(trimws(name))) return(NULL)
|
|
58
|
+
if (!format %in% SESSION_DOCUMENT_FORMATS) format <- "qmd"
|
|
59
|
+
list(path = path, name = trimws(name), format = format,
|
|
60
|
+
active = isTRUE(row$active))
|
|
61
|
+
}
|
|
62
|
+
cleaned <- Filter(Negate(is.null), lapply(rows, one))
|
|
63
|
+
# The same file listed twice is one document.
|
|
64
|
+
paths <- vapply(cleaned, \(row) row$path, character(1))
|
|
65
|
+
keep <- !nzchar(paths) | !duplicated(paths)
|
|
66
|
+
head(cleaned[keep], SESSION_DOCUMENTS_MAX)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#' The name a session goes by: its document, not its generated notebook name.
|
|
70
|
+
#'
|
|
71
|
+
#' Flattened for the runtime record, which helper-sessions.sh reads with a
|
|
72
|
+
#' bounded sed — a quote or a backslash would cut every field after it.
|
|
73
|
+
#'
|
|
74
|
+
#' @param documents A list from session_documents_clean().
|
|
75
|
+
#' @return "" when there are none; else the on-screen document's name (the
|
|
76
|
+
#' first one when none is marked), then " + N more" for the rest.
|
|
77
|
+
session_documents_label <- function(documents) {
|
|
78
|
+
if (!length(documents)) return("")
|
|
79
|
+
active <- Filter(\(row) isTRUE(row$active), documents)
|
|
80
|
+
lead <- if (length(active)) active[[1L]] else documents[[1L]]
|
|
81
|
+
name <- if (nzchar(lead$path)) basename(lead$path) else lead$name
|
|
82
|
+
if (!nzchar(lead$path)) name <- sprintf("%s (unsaved)", name)
|
|
83
|
+
rest <- length(documents) - 1L
|
|
84
|
+
label <- if (rest > 0L) sprintf("%s + %d more", name, rest) else name
|
|
85
|
+
trimws(substr(gsub("[\"\\\\]", "'", gsub("[[:cntrl:]]", " ", label)), 1L, 120L))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
#' Serialise for a successor supervisor's environment (a session handoff).
|
|
89
|
+
#' @param documents A list from session_documents_clean().
|
|
90
|
+
#' @return A JSON string; "" when there are none.
|
|
91
|
+
session_documents_encode <- function(documents) {
|
|
92
|
+
if (!length(documents)) return("")
|
|
93
|
+
as.character(jsonlite::toJSON(documents, auto_unbox = TRUE))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#' Read back what session_documents_encode() wrote; junk yields an empty list.
|
|
97
|
+
#' @param text A JSON string (possibly "").
|
|
98
|
+
#' @return A list from session_documents_clean().
|
|
99
|
+
session_documents_decode <- function(text) {
|
|
100
|
+
if (!is.character(text) || length(text) != 1L || !nzchar(text)) return(list())
|
|
101
|
+
parsed <- tryCatch(jsonlite::fromJSON(text, simplifyVector = TRUE),
|
|
102
|
+
error = function(e) NULL)
|
|
103
|
+
session_documents_clean(parsed)
|
|
104
|
+
}
|
package/host/engine-js.mjs
CHANGED
|
@@ -93,6 +93,10 @@ export class JsEngine extends StdioEngine {
|
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** process.kill, replaceable in tests. */
|
|
97
|
+
get killImpl() { return this._killImpl || process.kill.bind(process); }
|
|
98
|
+
set killImpl(fn) { this._killImpl = fn; }
|
|
99
|
+
|
|
96
100
|
/** There is no prompt behind this worker: a console line is refused, not typed. */
|
|
97
101
|
console() { throw new Error("The JavaScript engine has no console prompt."); }
|
|
98
102
|
}
|
package/host/engine-pool.mjs
CHANGED
|
@@ -77,6 +77,7 @@ export class EnginePool extends EventEmitter {
|
|
|
77
77
|
});
|
|
78
78
|
plane.sockets = () => this.sockets();
|
|
79
79
|
plane.on("broadcast", (payload) => this.emit("broadcast", payload));
|
|
80
|
+
plane.on("broadcast-pages", (payload) => this.emit("broadcast-pages", payload));
|
|
80
81
|
this.planes.set(spec.name, plane);
|
|
81
82
|
}
|
|
82
83
|
}
|
|
@@ -329,7 +330,7 @@ export class EnginePool extends EventEmitter {
|
|
|
329
330
|
* With no engine named it is the primary's — which is what "Restart R" in
|
|
330
331
|
* the page means and what every existing client sends.
|
|
331
332
|
*/
|
|
332
|
-
async restart(engine) {
|
|
333
|
+
async restart(engine, opts = {}) {
|
|
333
334
|
const key = engineKey(engine, this.primaryName);
|
|
334
335
|
const plane = this.planes.get(key);
|
|
335
336
|
if (!plane) return undefined;
|
|
@@ -338,9 +339,14 @@ export class EnginePool extends EventEmitter {
|
|
|
338
339
|
const r = await this.ensure(key);
|
|
339
340
|
return r.ok ? undefined : undefined;
|
|
340
341
|
}
|
|
341
|
-
return plane.restart();
|
|
342
|
+
return plane.restart(key === this.primaryName ? opts : {});
|
|
342
343
|
}
|
|
343
344
|
|
|
345
|
+
/** "Restart, keep variables" belongs to the primary (R) engine. */
|
|
346
|
+
setResumePending(token) { this.primary?.setResumePending(token); }
|
|
347
|
+
freshResumeReport(now) { return this.primary ? this.primary.freshResumeReport(now) : null; }
|
|
348
|
+
cancelSuspend(rec) { return this.primary ? this.primary.cancelSuspend(rec) : false; }
|
|
349
|
+
|
|
344
350
|
inputReply(cmd, rec) {
|
|
345
351
|
const waiting = [...this.planes.values()].find((p) => p.engine && p.inputWaiting);
|
|
346
352
|
if (waiting) return waiting.inputReply(cmd, rec);
|
package/host/engine-r.mjs
CHANGED
|
@@ -82,6 +82,11 @@ export function detectRBinary(rscript) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** One R worker process. */
|
|
85
|
+
/** readline's screen width for the interactive worker (see spawnPlan). */
|
|
86
|
+
export const READLINE_COLUMNS = 100000;
|
|
87
|
+
/** First on the boot line: give user code back the COLUMNS it started with (or none). */
|
|
88
|
+
export const R_WIDTH_BOOT = 'local({ cols <- Sys.getenv("CARMAR_USER_COLUMNS"); Sys.unsetenv("CARMAR_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") }); ';
|
|
89
|
+
|
|
85
90
|
export class REngine extends StdioEngine {
|
|
86
91
|
/**
|
|
87
92
|
* @param {Object} opts
|
|
@@ -105,6 +110,7 @@ export class REngine extends StdioEngine {
|
|
|
105
110
|
if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`REngine: no worker at ${workerPath}`);
|
|
106
111
|
if (!rscript) throw new Error("REngine: no Rscript found (set CARMAR_RSCRIPT)");
|
|
107
112
|
this.rscript = rscript;
|
|
113
|
+
this.userColumns = processEnv.COLUMNS == null ? "" : String(processEnv.COLUMNS);
|
|
108
114
|
// Windows: Rterm.exe --ess is the interactive worker (host/windows-runtime.mjs).
|
|
109
115
|
// CARMAR_WIN_BATCH=1 keeps the old batch worker, as an escape hatch should
|
|
110
116
|
// some R build refuse --ess with a piped stdin.
|
|
@@ -138,18 +144,19 @@ export class REngine extends StdioEngine {
|
|
|
138
144
|
// --no-echo suppresses the "> " prompt; the echo it does not suppress
|
|
139
145
|
// is scrubbed by LineFramer through the cmdtag.
|
|
140
146
|
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
// COLUMNS is readline's screen width when there is no terminal. At the
|
|
148
|
+
// default 80, readline's horizontal scrolling redraws a long command
|
|
149
|
+
// line as "<" + its tail — which carries no cmdtag, so on Linux (R 4.3.3,
|
|
150
|
+
// `beatrina check`, 2026-09-16) fragments of the wire command leaked into
|
|
151
|
+
// chunk output. A width no line reaches keeps the echo whole, tag and
|
|
152
|
+
// all, and the scrub drops it. It is readline's width only: the boot
|
|
153
|
+
// line restores the user's COLUMNS before any user code (R_WIDTH_BOOT),
|
|
154
|
+
// and getOption("width") never read it. --no-readline was the first fix
|
|
155
|
+
// and is WRONG: without readline R reads the console with stdio fgets,
|
|
156
|
+
// and a Stop at an idle prompt left the stream in error — test:aicoding
|
|
157
|
+
// and test:sessionrecovery hung. test/r-echo.test.mjs pins both.
|
|
158
|
+
args: ["--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
|
|
159
|
+
env: { CARMAR_WORKER_MODE: "interactive", COLUMNS: String(READLINE_COLUMNS), CARMAR_USER_COLUMNS: this.userColumns },
|
|
153
160
|
};
|
|
154
161
|
}
|
|
155
162
|
return {
|
|
@@ -174,7 +181,7 @@ export class REngine extends StdioEngine {
|
|
|
174
181
|
const attach = handoff
|
|
175
182
|
? `local({ e <- attach(NULL, name = "beatrix:handoff", warn.conflicts = FALSE); sys.source(${JSON.stringify(handoff)}, envir = e, keep.source = FALSE) }); `
|
|
176
183
|
: "";
|
|
177
|
-
this.rawLine(`${attach}sys.source(${JSON.stringify(worker)}, envir = globalenv(), keep.source = FALSE)`);
|
|
184
|
+
this.rawLine(`${R_WIDTH_BOOT}${attach}sys.source(${JSON.stringify(worker)}, envir = globalenv(), keep.source = FALSE)`);
|
|
178
185
|
}
|
|
179
186
|
|
|
180
187
|
/** engines/r/handoff.R for this host, or "" when this build has none. */
|
package/host/engine-stdio.mjs
CHANGED
|
@@ -91,8 +91,10 @@ export function engineEnvironment({ sentinel, cmdtag, workerDir, extra = {}, bas
|
|
|
91
91
|
* is our own command echoed back (kept only for the text before the tag).
|
|
92
92
|
*/
|
|
93
93
|
export class LineFramer {
|
|
94
|
-
constructor({ sentinel, cmdtag, channel }) {
|
|
94
|
+
constructor({ sentinel, cmdtag, channel, isEcho = null }) {
|
|
95
95
|
this.sentinel = sentinel;
|
|
96
|
+
// A raw console line the host itself wrote, echoed back whole by R's readline (Linux): not output.
|
|
97
|
+
this.isEcho = typeof isEcho === "function" ? isEcho : null;
|
|
96
98
|
this.tag = cmdtag ? `#${cmdtag}` : null;
|
|
97
99
|
this.channel = channel; // "stdout" | "stderr"
|
|
98
100
|
this.tail = "";
|
|
@@ -121,6 +123,7 @@ export class LineFramer {
|
|
|
121
123
|
const at = line.indexOf(this.tag);
|
|
122
124
|
if (at >= 0) { line = line.slice(0, at); if (!line) continue; }
|
|
123
125
|
}
|
|
126
|
+
if (this.isEcho && this.isEcho(line)) continue;
|
|
124
127
|
const at = line.indexOf(this.sentinel);
|
|
125
128
|
if (at < 0) { plain.push(line); continue; }
|
|
126
129
|
const prefix = line.slice(0, at);
|
|
@@ -241,7 +244,9 @@ export class StdioEngine extends EventEmitter {
|
|
|
241
244
|
});
|
|
242
245
|
this.alive = true;
|
|
243
246
|
this.pid = this.proc.pid;
|
|
244
|
-
|
|
247
|
+
// The boot line (and any raw console line) carries no command tag; readline echoes it whole.
|
|
248
|
+
this.recentRaw = [];
|
|
249
|
+
const out = new LineFramer({ sentinel: this.sentinel, cmdtag: this.cmdtag, channel: "stdout", isEcho: (l) => l.length > 40 && this.recentRaw.includes(l) });
|
|
245
250
|
const err = new LineFramer({ sentinel: this.sentinel, cmdtag: null, channel: "stderr" });
|
|
246
251
|
this.proc.stdout.setEncoding("utf8");
|
|
247
252
|
this.proc.stderr.setEncoding("utf8");
|
|
@@ -265,6 +270,9 @@ export class StdioEngine extends EventEmitter {
|
|
|
265
270
|
/** One RAW line on stdin, with no command framing. */
|
|
266
271
|
rawLine(line) {
|
|
267
272
|
if (/\n/.test(line)) throw new Error("StdioEngine.rawLine: one line, no newline");
|
|
273
|
+
// Remembered so an exact echo of it is not shown as output (LineFramer isEcho). Only lines long
|
|
274
|
+
// enough to be ours are matched: a short readline answer echoed as "Q? yes" is the user's own text.
|
|
275
|
+
this.recentRaw = [...(this.recentRaw || []), line].slice(-8);
|
|
268
276
|
return this.write(`${line}\n`);
|
|
269
277
|
}
|
|
270
278
|
|
package/host/main.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { PythonEngine, detectPython } from "./engine-python.mjs";
|
|
|
40
40
|
import { JsEngine, detectNode } from "./engine-js.mjs";
|
|
41
41
|
import { EnginePool } from "./engine-pool.mjs";
|
|
42
42
|
import { createHostServer, secureToken } from "./server.mjs";
|
|
43
|
+
import { decodeSessionDocuments, keepTokenOk, sessionDocumentsLabel } from "./session-keep.mjs";
|
|
43
44
|
import { createSettings } from "./settings.mjs";
|
|
44
45
|
// ── planes (one line per family; see the seam in server.mjs) ───────────────
|
|
45
46
|
// Each work package registers its module here and nowhere else. Keep the
|
|
@@ -346,6 +347,9 @@ async function main() {
|
|
|
346
347
|
host: "beatrix",
|
|
347
348
|
}),
|
|
348
349
|
});
|
|
350
|
+
// Set before the worker starts, so its first ready frame cannot arrive ahead of the token.
|
|
351
|
+
if (keepTokenOk(env("CARMAR_RESUME_TOKEN"))) plane.setResumePending(env("CARMAR_RESUME_TOKEN"));
|
|
352
|
+
delete process.env.CARMAR_RESUME_TOKEN;
|
|
349
353
|
await plane.start();
|
|
350
354
|
|
|
351
355
|
// ── the server ─────────────────────────────────────────────────────────────
|
|
@@ -361,6 +365,12 @@ async function main() {
|
|
|
361
365
|
// A successor started by a handoff keeps the session's name.
|
|
362
366
|
const inheritedTitle = env("CARMAR_SESSION_TITLE").trim();
|
|
363
367
|
if (inheritedTitle) runtimeRecord.title = inheritedTitle.slice(0, 120);
|
|
368
|
+
// A handoff successor starts with its predecessor's documents and, for "Restart, keep variables", the
|
|
369
|
+
// token its first worker restores (host/session-keep.mjs). Only a token travels, never a path.
|
|
370
|
+
const inheritedDocuments = decodeSessionDocuments(env("CARMAR_SESSION_DOCUMENTS"));
|
|
371
|
+
delete process.env.CARMAR_SESSION_DOCUMENTS;
|
|
372
|
+
if (sessionDocumentsLabel(inheritedDocuments)) runtimeRecord.documents = sessionDocumentsLabel(inheritedDocuments);
|
|
373
|
+
|
|
364
374
|
const handoffFrom = env("CARMAR_HANDOFF_FROM").trim();
|
|
365
375
|
const writeRuntime = () => {
|
|
366
376
|
if (!runtimeFile) return false;
|
|
@@ -396,6 +406,14 @@ async function main() {
|
|
|
396
406
|
notebookPage: (build) => notebookPage(build, { fallback: false }), notebookFileUrl: (p, prt, cap) => notebookFileUrl(path.resolve(p), prt, cap),
|
|
397
407
|
runtimeRecord: () => runtimeRecord, handoffFrom,
|
|
398
408
|
pendingOpen: env("CARMAR_OPEN_FILE") || null,
|
|
409
|
+
sessionDocuments: inheritedDocuments,
|
|
410
|
+
// The menu helper names a session by its document ("SezerProfiles.qmd + 1 more").
|
|
411
|
+
onSessionDocuments: (documents) => {
|
|
412
|
+
const label = sessionDocumentsLabel(documents);
|
|
413
|
+
if ((runtimeRecord.documents || "") === label) return;
|
|
414
|
+
if (label) runtimeRecord.documents = label; else delete runtimeRecord.documents;
|
|
415
|
+
writeRuntime();
|
|
416
|
+
},
|
|
399
417
|
onShutdown: (reason) => shutdown(reason || "explicit Quit request"),
|
|
400
418
|
onPageTitle: (title) => {
|
|
401
419
|
// eslint-disable-next-line no-control-regex
|
|
@@ -441,10 +459,16 @@ async function main() {
|
|
|
441
459
|
audit("started", { port: boundPort, root: env("CARMAR_ROOT") });
|
|
442
460
|
process.stdout.write(`${JSON.stringify({ url, file: fileUrl })}\n`);
|
|
443
461
|
// --open: open the notebook FILE, as serve.R did for the double-click
|
|
444
|
-
// launchers.
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
462
|
+
// launchers. --open=url opens the page THIS kernel serves instead
|
|
463
|
+
// (http://127.0.0.1:<port>/): the one origin the socket allows outright, so
|
|
464
|
+
// the page connects with no fragment and no pairing. The `beatrina` command
|
|
465
|
+
// uses it, because macOS `open` and Finder drop a file URL's #kernel=…&pair=…
|
|
466
|
+
// fragment and the page then cannot find its kernel ("needs R", 2026-09-16).
|
|
467
|
+
// The kernel stays hidden plumbing. CARMAR_OPENER is the seam a test uses so
|
|
468
|
+
// nothing opens on a real desktop.
|
|
469
|
+
const openArg = process.argv.slice(2).find((a) => a === "--open" || a === "--open=url" || a === "--open=file");
|
|
470
|
+
if (openArg) {
|
|
471
|
+
const target = openArg === "--open=url" ? url : (fileUrl || url);
|
|
448
472
|
const opener = String(process.env.CARMAR_OPENER || "");
|
|
449
473
|
try {
|
|
450
474
|
const child = path.isAbsolute(opener) ? spawn(opener, [target], { stdio: "ignore", detached: true })
|
package/host/planes/latex.mjs
CHANGED
|
@@ -124,6 +124,32 @@ export function latexPreviewInline(html, dir) {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** Render one LaTeX source to HTML for the preview pane. */
|
|
127
|
+
/**
|
|
128
|
+
* Highlighted code blocks as plain verbatim, for the PREVIEW only — the host's twin of spike/latex.R
|
|
129
|
+
* latex_preview_code() (CarmaR 0.8.6), rule for rule.
|
|
130
|
+
*
|
|
131
|
+
* The export writes R chunks as fancyvrb `Highlighting` blocks (lib/md/latex.js), correct for a TeX
|
|
132
|
+
* engine, but pandoc's LaTeX reader does not know that environment is verbatim and rendered each block
|
|
133
|
+
* as an EMPTY div: the preview showed a document with its code missing while the PDF was right. Each
|
|
134
|
+
* block becomes `\begin{verbatim}` with the token macros dropped and lib/md/latex.js CODE_CH undone.
|
|
135
|
+
* Escapes become sentinels BEFORE the macro braces are stripped, so a literal `{` survives; a code line
|
|
136
|
+
* holding `\end{verbatim}` gains a zero-width space after the backslash. The file on disk and the
|
|
137
|
+
* compile are untouched. Pinned by test/latex-preview-code.test.mjs, against a real pandoc.
|
|
138
|
+
*/
|
|
139
|
+
const PREVIEW_CODE_BLOCK = /(?:\\begin\{Shaded\}[ \t]*\n?)?\\begin\{Highlighting\}(?:\[[^\]\n]*\])?\n?([\s\S]*?)\n?\\end\{Highlighting\}(?:[ \t]*\n?\\end\{Shaded\})?/g;
|
|
140
|
+
const PREVIEW_CODE_SWAPS = [["\\textbackslash{}", "\u0001"], ["\\textasciitilde{}", "~"], ["\\^{}", "^"], ["\\textless{}", "<"], ["\\textgreater{}", ">"],
|
|
141
|
+
["{-}", "-"], ["\\{", "\u0002"], ["\\}", "\u0003"], ["\\#", "#"], ["\\%", "%"], ["\\_", "_"], ["\\&", "&"]];
|
|
142
|
+
export function latexPreviewCode(source) {
|
|
143
|
+
if (typeof source !== "string") return source;
|
|
144
|
+
return source.replace(PREVIEW_CODE_BLOCK, (_, inner) => {
|
|
145
|
+
let body = PREVIEW_CODE_SWAPS.reduce((text, [from, to]) => text.split(from).join(to), inner);
|
|
146
|
+
body = body.replace(/\\[A-Za-z]+Tok\{/g, "").replace(/[{}]/g, "");
|
|
147
|
+
body = body.replace(/\u0001/g, "\\").replace(/\u0002/g, "{").replace(/\u0003/g, "}");
|
|
148
|
+
body = body.split("\\end{verbatim}").join("\\\u200bend{verbatim}");
|
|
149
|
+
return `\\begin{verbatim}\n${body}\n\\end{verbatim}`;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs = LATEX_PREVIEW_TIMEOUT_MS } = {}) {
|
|
128
154
|
if (!scalar(source) || source.length === 0) return { ok: false, error: "No LaTeX source to preview." };
|
|
129
155
|
if (bytes(source) > LATEX_PREVIEW_MAX_SOURCE) return { ok: false, error: "This document is too large to preview while you type." };
|
|
@@ -135,7 +161,7 @@ export async function latexPreview(source, docPath, buffers, { pandoc, timeoutMs
|
|
|
135
161
|
if (buffers && typeof buffers === "object" && !Array.isArray(buffers)) {
|
|
136
162
|
Object.keys(buffers).filter(Boolean).slice(0, LATEX_PREVIEW_MAX_FILES).forEach((k) => { if (typeof buffers[k] === "string") keep[k] = buffers[k]; });
|
|
137
163
|
}
|
|
138
|
-
const expanded = dir ? latexPreviewExpand(source, dir, keep) : source;
|
|
164
|
+
const expanded = latexPreviewCode(dir ? latexPreviewExpand(source, dir, keep) : source);
|
|
139
165
|
const started = process.hrtime.bigint();
|
|
140
166
|
const args = ["--from=latex", "--to=html5", "--mathml", "--wrap=none", "--standalone", "--sandbox"];
|
|
141
167
|
if (dir) latexPreviewBibliography(expanded, dir).forEach((bib) => args.push("--citeproc", `--bibliography=${bib}`));
|