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/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), auto_unbox = TRUE)
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
+ }
@@ -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
  }
@@ -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
- // --no-readline FIRST. With readline, R echoes every line it reads back
142
- // on stdout and on Linux (R 4.3.3, 2026-09-16) readline's horizontal
143
- // scrolling redraws a long command line as "<" + its tail, which no
144
- // longer carries the cmdtag, so the echo leaked into chunk output
145
- // (`beatrina check` on .39 read it as "the line after Stop ran").
146
- // R honours --no-readline only BEFORE --interactive: after it, the flag
147
- // is ignored and capabilities("cledit") stays TRUE (measured on Linux
148
- // R 4.3.3 and macOS R 4.5.2). Without readline R reads plain lines,
149
- // echoes nothing, and readline()/browser() still read the console.
150
- // test/r-echo.test.mjs pins both the order and the silence.
151
- args: ["--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file"],
152
- env: { CARMAR_WORKER_MODE: "interactive" },
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. */
@@ -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
- const out = new LineFramer({ sentinel: this.sentinel, cmdtag: this.cmdtag, channel: "stdout" });
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. The kernel stays hidden plumbing. CARMAR_OPENER is the seam a
445
- // test uses so nothing opens on a real desktop.
446
- if (process.argv.slice(2).includes("--open")) {
447
- const target = fileUrl || url;
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 })
@@ -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}`));