beatrina 0.8.6

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 (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. package/package.json +32 -0
@@ -0,0 +1,3496 @@
1
+ #!/usr/bin/env Rscript
2
+ #
3
+ # Worker — evaluates R code on behalf of the supervisor.
4
+ #
5
+ # Protocol
6
+ # stdin NDJSON commands, one per line: {"type":"exec","id":...,"source":...}
7
+ # stdout the user's own output, untouched, PLUS control frames: a line
8
+ # beginning with the session sentinel followed by JSON.
9
+ # stderr R diagnostics; notebook execution also frames protocol here while
10
+ # evaluate owns stdout's capture sink (including that run's done).
11
+ #
12
+ # Frames emitted per cell: `stream` (warning/message), `plot` (one per graphics
13
+ # page, base64 PNG), `dataframe` (a structured top-level data.frame), and
14
+ # exactly one `done`.
15
+ #
16
+ # Why no output capture: `sink()` / `capture.output()` buffer, which destroys
17
+ # streaming — the caller sees nothing until the cell finishes. Letting R print
18
+ # straight to stdout keeps output real-time and costs nothing, as long as
19
+ # control frames are distinguishable. The sentinel is a per-session random
20
+ # token supplied by the supervisor, so user code cannot forge a frame.
21
+ #
22
+ # Why a device rather than source-sniffing: the notebook owns this session, so
23
+ # it can install a graphics device once and let every page land in it. Guessing
24
+ # whether a cell plots by grepping its source for "plot(" misses plots drawn
25
+ # inside functions and fires on a variable named `myplot`.
26
+
27
+ # Finder/launchd can start the app with LC_CTYPE=C even on a UTF-8 Mac. In that
28
+ # locale R prints box drawing and other non-ASCII text as escapes, and knit can
29
+ # only preserve the already-corrupted output. Select an installed UTF-8 CTYPE
30
+ # inside the worker; keep every other locale category (numbers, dates, sorting)
31
+ # untouched. The candidates cover current Linux, macOS, and older R builds.
32
+ if (!isTRUE(l10n_info()[["UTF-8"]])) {
33
+ for (candidate in c("C.UTF-8", "en_US.UTF-8", "UTF-8")) {
34
+ suppressWarnings(try(Sys.setlocale("LC_CTYPE", candidate), silent = TRUE))
35
+ if (isTRUE(l10n_info()[["UTF-8"]])) break
36
+ }
37
+ }
38
+
39
+ # Finder also starts apps with the filesystem ROOT as their working directory,
40
+ # and everything downstream inherits it: getwd() is "/", so every native file
41
+ # dialog opens at the Computer view, "Reveal" reveals the root, and relative
42
+ # paths land where nobody looks. Exactly "/" is the pathological case — a
43
+ # kernel started from a terminal keeps that terminal's directory, which is the
44
+ # developer's own choice and is left alone.
45
+ if (identical(getwd(), "/")) {
46
+ try(setwd(path.expand("~")), silent = TRUE)
47
+ }
48
+
49
+ # jsonlite is used via :: only — attaching it would put it on the USER's search
50
+ # path, and the worker must leave no trace in the session it hosts.
51
+ stopifnot(requireNamespace("jsonlite", quietly = TRUE))
52
+
53
+ # Everything below lives in a private scope. Two reasons, both load-bearing:
54
+ # the Environment pane must show the USER's objects and not the kernel's
55
+ # plumbing, and user code must not be able to clobber `emit` or `sentinel` and
56
+ # break the protocol from inside the session it is running in.
57
+ local({
58
+
59
+ # Batch mode (Rscript worker-boot.R <sentinel>) passes the sentinel in argv.
60
+ # Interactive mode (R --interactive, booted by a sys.source line on stdin)
61
+ # has no argv to give, so the tokens arrive in the environment instead.
62
+ # WORKER_MODE decides which reader serves the dispatch loop below: the
63
+ # interactive worker MUST read through R's console (stdin()), because that is
64
+ # the one reader a native browser() prompt shares — a second buffered reader
65
+ # on the same fd would steal bytes from the debugger.
66
+ WORKER_MODE <- Sys.getenv("CARMAR_WORKER_MODE", "batch")
67
+ sentinel <- if (identical(WORKER_MODE, "interactive")) {
68
+ Sys.getenv("CARMAR_SENTINEL", "")
69
+ } else {
70
+ args <- commandArgs(trailingOnly = TRUE)
71
+ if (length(args) >= 1L) args[1] else ""
72
+ }
73
+ stopifnot(nzchar(sentinel))
74
+ # Commands from the supervisor arrive as "#<cmdtag> {json}" in interactive
75
+ # mode — a comment, so a line that ever reached R's raw top level would be
76
+ # inert. The prefix is stripped here before parsing.
77
+ CMD_PREFIX <- local({
78
+ tag <- Sys.getenv("CARMAR_CMD_TAG", "")
79
+ if (nzchar(tag)) paste0("#", tag, " ") else ""
80
+ })
81
+
82
+ PLOT_WIDTH <- 900L
83
+ PLOT_HEIGHT <- 620L
84
+ PLOT_RES <- 110L
85
+ # Hard ceilings for the data viewer. Server-side because the client is one
86
+ # `limit: 1e9` typo away from asking for everything; the reply reports what was
87
+ # clamped, so the UI never has to guess what it actually received.
88
+ MAX_VIEW_ROWS <- 500L
89
+ MAX_VIEW_COLS <- 100L
90
+ MAX_VIEW_BYTES <- 512L * 1024L
91
+ MAX_VIEW_CELL_CHARS <- 512L
92
+ MAX_VIEW_LABEL_CHARS <- 128L
93
+ MAX_STRUCT_CHILDREN <- 200L
94
+ MAX_COMPLETIONS <- 200L
95
+
96
+ # Path confinement (confine_root / within_root / outside_root_msg) and the file
97
+ # ops live in fileio.R, sourced below with sniff.R.
98
+ # R ships the completion engine RStudio and Jupyter both drive; these knobs
99
+ # make package names (ipck), function signatures (func → trailing "("),
100
+ # argument names (args → trailing "=") and quoted file paths (files) all
101
+ # participate. try(): a future R renaming a setting must not kill the boot.
102
+ try(utils::rc.settings(ipck = TRUE, func = TRUE, args = TRUE, files = TRUE),
103
+ silent = TRUE)
104
+
105
+ # ── the import sniffer ──────────────────────────────────────────────────────
106
+ # Format and column-type detection lives in its own file so it can be tested
107
+ # without starting a worker (test/import-sniff.test.R sources it directly);
108
+ # this file runs a dispatch loop the moment it loads, so nothing inside it is
109
+ # reachable from a test. `local = TRUE` keeps the functions in the worker's
110
+ # private scope, where the rest of the plumbing lives — the Environment pane
111
+ # must keep showing the user's objects and not the kernel's.
112
+ #
113
+ # Resolved from the WORKER'S OWN path, not the working directory: the worker
114
+ # is started by three different launchers (kernel.R, the Chrome bridge host,
115
+ # and the packaged inst/app/kernel) and none of them guarantee a cwd.
116
+ import_sources <- local({
117
+ env_dir <- Sys.getenv("CARMAR_WORKER_DIR", "")
118
+ file_arg <- grep("^--file=", commandArgs(FALSE), value = TRUE)
119
+ here <- if (nzchar(env_dir)) env_dir
120
+ else if (length(file_arg)) {
121
+ dirname(normalizePath(sub("^--file=", "", file_arg[1L]), mustWork = FALSE))
122
+ } else getwd()
123
+ file.path(here, c("fileio.R", "sniff.R", "project.R"))
124
+ })
125
+ # environment(), not parent.frame(): inside a nested local() the parent frame
126
+ # is the eval machinery's, not this file's private scope, and the functions
127
+ # landed somewhere the handlers could not see them. Caught end-to-end, where
128
+ # the kernel answered "this kernel is too old for the import wizard".
129
+ # The target environment is captured HERE, at the worker's own scope. Calling
130
+ # environment() inside the lambda would name the lambda's frame and the
131
+ # functions would load somewhere no handler can see — the same mistake this
132
+ # block already made once, with sniff.R, and it surfaced only end-to-end as
133
+ # "this kernel is too old for the import wizard".
134
+ import_env <- environment()
135
+ invisible(lapply(Filter(file.exists, import_sources),
136
+ function(f) sys.source(f, envir = import_env)))
137
+
138
+ # `Rscript` starts with repos = "@CRAN@", a placeholder that resolves only by
139
+ # ASKING the user to pick a mirror — which a non-interactive session cannot do,
140
+ # so install.packages() fails with "trying to use CRAN without setting a
141
+ # mirror" before it does anything. Fill it in, but never override a mirror the
142
+ # user already set: their .Rprofile loads here (we deliberately do not use
143
+ # --vanilla), and it is where r-universe and institutional mirrors live.
144
+ local({
145
+ managed_mirror <- trimws(Sys.getenv("CARMAR_CRAN_MIRROR", ""))
146
+ if (nzchar(managed_mirror) && !grepl("^https://", managed_mirror)) {
147
+ stop("CARMAR_CRAN_MIRROR must be an HTTPS URL.")
148
+ }
149
+ repos <- getOption("repos")
150
+ cran <- if (is.null(repos)) NA_character_ else unname(repos["CRAN"])
151
+ unset <- is.null(repos) || is.na(cran) || !nzchar(cran) || identical(cran, "@CRAN@")
152
+ if (nzchar(managed_mirror)) {
153
+ options(repos = unlist(utils::modifyList(as.list(if (is.null(repos)) character() else repos),
154
+ list(CRAN = managed_mirror))))
155
+ Sys.setenv(RENV_CONFIG_REPOS_OVERRIDE = managed_mirror)
156
+ } else if (unset) {
157
+ # Keep every other repo the profile declared; only CRAN was missing.
158
+ # modifyList says that in one verb: it replaces CRAN where it exists and
159
+ # appends it where it does not, leaving r-universe and institutional
160
+ # mirrors exactly as the user's .Rprofile left them.
161
+ others <- if (is.null(repos)) list() else as.list(repos)
162
+ options(repos = unlist(utils::modifyList(
163
+ others, list(CRAN = "https://cloud.r-project.org"))))
164
+ }
165
+ })
166
+
167
+ # An interactive R ASKS where a batch R silently defaulted, and every such
168
+ # question is a console read that would consume protocol lines and wedge the
169
+ # session. Close the known askers: install.packages' "install from sources
170
+ # which need compilation?" question, and any menu() falling back to a
171
+ # graphical chooser it cannot open.
172
+ if (identical(WORKER_MODE, "interactive")) {
173
+ options(install.packages.compile.from.source = "never",
174
+ menu.graphics = FALSE)
175
+ }
176
+
177
+ # The reserved words R's engine offers with "(" appended (for/if/while) or
178
+ # bare; they are neither functions nor variables and the UI badges them apart.
179
+ R_KEYWORDS <- c("if", "else", "repeat", "while", "function", "for", "in",
180
+ "next", "break", "TRUE", "FALSE", "NULL", "Inf", "NaN",
181
+ "NA", "NA_integer_", "NA_real_", "NA_character_", "NA_complex_")
182
+
183
+ #' Find a graphics device type that actually WORKS in this R.
184
+ #'
185
+ #' `capabilities("cairo")` reports what R was COMPILED with, not what loads.
186
+ #' On the CRAN macOS build it says TRUE while `png(type="cairo")` fails to
187
+ #' dlopen (it wants libXrender from XQuartz) and silently produces no file —
188
+ #' so every plot vanished with no error anywhere. The only honest test is to
189
+ #' open a device, draw a page, and see whether bytes appear.
190
+ #'
191
+ #' CAIRO IS TRIED BEFORE QUARTZ, and that ORDER is a hang fix, not a
192
+ #' preference. `png(type="quartz")` pulls in the macOS Aqua/AppKit graphics
193
+ #' backend, which spawns a Cocoa event loop (an NSEventThread plus grDevices'
194
+ #' own ELThread) the first time it opens. CarmaR's worker is a BackgroundOnly
195
+ #' process with no window-server access, so when R pumps that event loop
196
+ #' mid-evaluation — which it does from inside a long R-level loop, e.g. the
197
+ #' permutation loop in `markov_order_test` — `ReceiveNextEventCommon` blocks
198
+ #' and the whole session hangs at 0% CPU, uninterruptibly (Stop is a SIGINT R
199
+ #' never reaches). It only bites a GUI-launched worker: a terminal R is a
200
+ #' different session and never blocks, which is exactly why it presented as
201
+ #' "only this one function hangs, and only in the app." Cairo renders the
202
+ #' identical PNG headlessly with no AppKit thread at all, and — because Cairo
203
+ #' is opened first and works — quartz is never even probed, so the event loop
204
+ #' is never created. The `works()` guard below still protects the broken-CRAN
205
+ #' case: if this R's Cairo is the libXrender-less one, it falls through to
206
+ #' quartz, and a machine that reaches that fallback is not the GUI worker the
207
+ #' hang needs anyway.
208
+ #'
209
+ #' THE TYPE VOCABULARY IS PER-PLATFORM, and getting that wrong is what
210
+ #' produced "no cairo dll" on Windows. `png(type=)` accepts "windows",
211
+ #' "cairo" and "cairo-png" on Windows; "cairo", "quartz" and "Xlib" on
212
+ #' Unix-alikes. The old ladder was cairo → quartz → Xlib on every platform,
213
+ #' which on Windows means: cairo, then a type that CANNOT work there. So a
214
+ #' Windows R whose cairo.dll is missing — a stripped or portable install, or
215
+ #' one where grDevices' cairo was not shipped — had no reachable candidate.
216
+ #'
217
+ #' It then hit the second half of the bug. `if (is.na(hit)) candidates[1L]`
218
+ #' returns the FIRST candidate when nothing works, and the first candidate is
219
+ #' cairo — the one just proven broken. PLOT_TYPE became "cairo", and every
220
+ #' plot after that raised the cairo.dll load error at the user. The fallback
221
+ #' is now the platform's OWN default, which on Windows is the GDI device and
222
+ #' renders perfectly well without cairo.
223
+ #'
224
+ #' `os` and `works` are arguments so the ladder can be tested for every
225
+ #' platform from one machine (spike/test-plot-type.R).
226
+ #'
227
+ #' @param os One of "windows" or "unix" (.Platform$OS.type).
228
+ #' @param mac Whether this is macOS (quartz only exists there).
229
+ #' @return The first working type; failing that, the platform's own default.
230
+ detect_plot_type <- function(os = .Platform$OS.type,
231
+ mac = isTRUE(capabilities("aqua")),
232
+ works = NULL) {
233
+ windows <- identical(os, "windows")
234
+ candidates <- if (windows) {
235
+ # No Xlib and no quartz on Windows, ever. "windows" is the GDI device R
236
+ # itself defaults to and needs no external library at all.
237
+ c(if (isTRUE(capabilities("cairo"))) "cairo", "windows")
238
+ } else {
239
+ c(if (isTRUE(capabilities("cairo"))) "cairo",
240
+ if (mac) "quartz",
241
+ "Xlib")
242
+ }
243
+ if (is.null(works)) {
244
+ works <- function(type) {
245
+ f <- tempfile(fileext = ".png")
246
+ ok <- tryCatch({
247
+ # 200x200, not 12x12. plot.new() on a 12-pixel canvas raises "figure
248
+ # margins too large" — a PLOTTING error about the canvas, nothing to do
249
+ # with the device — and the tryCatch below turned that into "this
250
+ # device does not work". Measured: quartz fails the 12px probe and
251
+ # renders perfectly at 200. A probe that rejects a working device
252
+ # sends the worker down the ladder for no reason, and on Windows the
253
+ # bottom of that ladder was the broken cairo. Same size the ragg probe
254
+ # already uses.
255
+ suppressWarnings(grDevices::png(f, width = 200, height = 200, type = type))
256
+ graphics::plot.new()
257
+ grDevices::dev.off()
258
+ file.exists(f) && file.info(f)$size > 0L
259
+ }, error = function(e) FALSE, warning = function(w) FALSE)
260
+ if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
261
+ unlink(f)
262
+ isTRUE(ok)
263
+ }
264
+ }
265
+ hit <- Position(works, candidates)
266
+ if (!is.na(hit)) return(candidates[hit])
267
+ # Nothing rendered. Return what R itself would use rather than the first
268
+ # candidate, which is the type the probe just disproved.
269
+ if (windows) "windows" else if (mac) "quartz" else "Xlib"
270
+ }
271
+
272
+ #' The raster backend for cell plots. `ragg::agg_png` is a pure-C++ device with
273
+ #' NO quartz and NO cairo, so it never opens the macOS AppKit graphics subsystem
274
+ #' — and it is the only headless raster that both actually loads on a stock
275
+ #' framework R (whose cairo fails to dlopen; see detect_plot_type) AND does not
276
+ #' spawn the Cocoa event loop that hangs a BackgroundOnly worker. Probed the same
277
+ #' honest way as the png types — an installed-but-broken ragg must fall through.
278
+ #'
279
+ #' @return "ragg" when agg_png renders bytes, else "png" (use grDevices::png).
280
+ detect_raster_device <- function() {
281
+ if (!requireNamespace("ragg", quietly = TRUE)) return("png")
282
+ f <- tempfile(fileext = ".png")
283
+ ok <- tryCatch({
284
+ # A real-sized canvas with zero margins: a 12x12-pixel probe made
285
+ # plot.new() raise "figure margins too large", which this tryCatch counted
286
+ # as ragg FAILING — so the worker fell through to the png/quartz path and
287
+ # opened the AppKit event loop it was trying to avoid. The probe must fail
288
+ # only when ragg genuinely does not render.
289
+ ragg::agg_png(f, width = 200, height = 200, res = 72)
290
+ old <- graphics::par(mar = c(0, 0, 0, 0))
291
+ graphics::plot.new()
292
+ graphics::par(old)
293
+ grDevices::dev.off()
294
+ file.exists(f) && file.info(f)$size > 0L
295
+ }, error = function(e) FALSE, warning = function(w) FALSE)
296
+ if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
297
+ unlink(f)
298
+ if (isTRUE(ok)) "ragg" else "png"
299
+ }
300
+
301
+ RASTER_DEVICE <- detect_raster_device()
302
+ # Probing png TYPES opens a quartz device on macOS — the very thing whose AppKit
303
+ # event loop hangs a background worker — so a worker that has ragg must never run
304
+ # detect_plot_type() at all. Only compute PLOT_TYPE when the png fallback is what
305
+ # we will actually use.
306
+ PLOT_TYPE <- if (identical(RASTER_DEVICE, "png")) detect_plot_type() else NA_character_
307
+
308
+ #' Find a VECTOR device that actually works, or NULL.
309
+ #'
310
+ #' Same discipline as detect_plot_type and for a sharper reason: on the CRAN
311
+ #' macOS build `grDevices::svg()` opens without error, draws without error,
312
+ #' and writes a ZERO-BYTE file, because it is Cairo and Cairo cannot dlopen
313
+ #' without XQuartz. Measured on this machine. So the probe draws a page and
314
+ #' insists on bytes that actually begin an SVG document.
315
+ #'
316
+ #' Unlike detect_plot_type this returns NULL rather than falling through to a
317
+ #' first candidate: "no vector device" is a real answer the caller must handle
318
+ #' by saying so, not by silently producing something else.
319
+ #'
320
+ #' @return "svglite", "grDevices", or NULL.
321
+ detect_svg_device <- function() {
322
+ works <- function(open) {
323
+ f <- tempfile(fileext = ".svg")
324
+ ok <- tryCatch({
325
+ suppressWarnings(open(f))
326
+ graphics::plot.new()
327
+ graphics::text(0.5, 0.5, "x")
328
+ grDevices::dev.off()
329
+ file.exists(f) && file.info(f)$size > 0L &&
330
+ grepl("<svg", paste(readLines(f, n = 8L, warn = FALSE), collapse = ""), fixed = TRUE)
331
+ }, error = function(e) FALSE, warning = function(w) FALSE)
332
+ if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
333
+ unlink(f)
334
+ isTRUE(ok)
335
+ }
336
+ # svglite first: pure C++, no Cairo, and the only one that works on a stock
337
+ # macOS R. grDevices::svg() is the fallback for machines that have Cairo.
338
+ if (requireNamespace("svglite", quietly = TRUE) &&
339
+ works(function(f) svglite::svglite(f, width = 2, height = 2))) return("svglite")
340
+ if (works(function(f) grDevices::svg(f, width = 2, height = 2))) return("grDevices")
341
+ NULL
342
+ }
343
+
344
+ SVG_DEVICE <- detect_svg_device()
345
+
346
+ #' Is this chunk asking for vector output, and can we give it?
347
+ plot_is_svg <- function(dims) isTRUE(dims$format == "svg") && !is.null(SVG_DEVICE)
348
+
349
+ #' Write one control frame to stdout and flush immediately.
350
+ #'
351
+ #' @param obj Named list, serialised to JSON.
352
+ #' @return Invisibly NULL.
353
+ emit_wire <- function(obj) {
354
+ # evaluate owns an output sink during notebook execution. Keep protocol
355
+ # frames on the other pipe for that whole command (including done), so
356
+ # output capture cannot swallow them or settle the command before its data.
357
+ message_sink <- sink.number(type = "message")
358
+ if (isTRUE(KNIT_PROTOCOL) && message_sink != 2L) {
359
+ # A user's message log must not swallow the command's done frame. Message
360
+ # sinks have one current connection (not an output-sink stack), so it can
361
+ # be restored exactly after this private protocol write.
362
+ sink(type = "message")
363
+ on.exit(try(sink(getConnection(message_sink), type = "message"), silent = TRUE), add = TRUE)
364
+ }
365
+ channel <- if (isTRUE(KNIT_PROTOCOL)) stderr() else stdout()
366
+ cat(sentinel, jsonlite::toJSON(obj, auto_unbox = TRUE, null = "null", na = "null",
367
+ digits = NA), "\n", sep = "", file = channel)
368
+ flush(channel)
369
+ invisible(NULL)
370
+ }
371
+ emit <- function(obj) {
372
+ if (is.null(KNIT_CAPTURE) || isTRUE(KNIT_CAPTURE(obj))) emit_wire(obj)
373
+ invisible(NULL)
374
+ }
375
+ here <- dirname(import_sources[[1L]])
376
+ sys.source(file.path(here, "knitr-run.R"), envir = environment())
377
+
378
+ # ── interactive input ────────────────────────────────────────────────────────
379
+ #
380
+ # The worker runs under `R --interactive`, so base::readline() genuinely blocks
381
+ # on the console — the SAME reader the debugger's Browse prompt uses, which is
382
+ # why answering it needs no new channel: the supervisor writes a raw console
383
+ # line exactly as it writes a debug step.
384
+ #
385
+ # What was missing was the announcement. R sat waiting on a prompt while the
386
+ # notebook sat on a spinner with no way to answer it, so any code that asked a
387
+ # question — readline(), and the menu()/select.list() built on it — could only
388
+ # be stopped, never completed.
389
+ #
390
+ # `readline` is SHADOWED on the search path rather than replaced in base: user
391
+ # code and every package keep seeing base's own function, and removing the
392
+ # attached frame restores the original behaviour exactly. The delegation is the
393
+ # whole implementation — announce, then let R do what it already did.
394
+ carmar_readline <- function(prompt = "") {
395
+ text <- tryCatch(as.character(prompt)[[1]], error = function(e) "")
396
+ if (!length(text) || is.na(text)) text <- ""
397
+ # The question belongs to the RUN that asked it, and says so.
398
+ #
399
+ # It carried no id until 7.20, and serve.R adopts `worker_active` as the wire
400
+ # id only for stdout/stderr/debug — so an input_request fell through to a
401
+ # BROADCAST. Three consequences, all of them the same defect: the prompt
402
+ # reached every open tab, the ownership check on the answer (serve.R
403
+ # input_reply) then refused all of them but one, and a reload lost the
404
+ # question entirely while preserving the computation blocked on it — because
405
+ # a broadcast frame is not recorded on any route and so is not replayed when
406
+ # the run is adopted. With the id it is a routed frame like every other, and
407
+ # adoption replays it to the page that comes back.
408
+ #
409
+ # RUN_STATE$id is NULL outside run_cell, and then there is no owner to name;
410
+ # the frame goes out id-less exactly as it always did.
411
+ #
412
+ # `id` SECOND, like every other emit site: relay_frame rewrites the routing
413
+ # id by finding the first `"id":…` in the worker's own bytes, so a prompt
414
+ # containing that text would otherwise be rewritten instead of the id.
415
+ emit(list(type = "input_request", id = RUN_STATE$id, prompt = text))
416
+ answer <- base::readline(prompt)
417
+ # Symmetry matters more than it looks: a page that opened an input row on the
418
+ # request must be told to close it, INCLUDING when the answer arrived by some
419
+ # other route (an interrupt, a second page). Without this the prompt row
420
+ # outlives the question it asked. Same id, for the same reason — a routed
421
+ # request answered by a broadcast would close the row in tabs that never
422
+ # opened one.
423
+ emit(list(type = "input_done", id = RUN_STATE$id))
424
+ answer
425
+ }
426
+ # `print(df)` joins the same shadow, for the same reason readline did: the
427
+ # machinery already exists, nothing announced the moment. Autoprint sends a
428
+ # visible data.frame as a structured `dataframe` frame; an EXPLICIT print()
429
+ # returned invisibly and streamed 51 rows of console text, so the two spellings
430
+ # of "show me this table" produced different universes. The shadow narrows to
431
+ # exactly the calls where table intent is unambiguous — a data.frame or matrix,
432
+ # no formatting arguments — and delegates everything else to base untouched:
433
+ # - extra args (`print(df, digits = 3)`) ask for R's console formatting; honor it.
434
+ # - an active sink means the caller is CAPTURING text (capture.output); a
435
+ # frame emitted there would be captured with it — corrupted for the caller,
436
+ # lost to the page.
437
+ # - no run id means no page is listening; base behaviour is the only one.
438
+ # RUN_STATE carries the current run's id from run_cell to the shadow.
439
+ RUN_STATE <- new.env(parent = emptyenv())
440
+ RUN_STATE$id <- NULL
441
+ carmar_print <- function(x, ...) {
442
+ if (is.null(RUN_STATE$id) || length(list(...)) || sink.number() > (RUN_STATE$capture_depth %||% 0L)) {
443
+ return(base::print(x, ...))
444
+ }
445
+ if (is.data.frame(x)) {
446
+ emit_dataframe(RUN_STATE$id, x)
447
+ return(invisible(x))
448
+ }
449
+ if (is.matrix(x) && nrow(x) > 0L && ncol(x) > 0L) {
450
+ emit_dataframe(RUN_STATE$id, matrix_to_df(x))
451
+ return(invisible(x))
452
+ }
453
+ rich <- rich_html_of(x)
454
+ if (!is.null(rich)) {
455
+ emit_rich(RUN_STATE$id, rich)
456
+ return(invisible(x))
457
+ }
458
+ base::print(x, ...)
459
+ }
460
+ INPUT_SHADOW <- "carmar:input"
461
+ carmar_flush_console <- function() {
462
+ if (!is.null(KNIT_CAPTURE)) evaluate::flush_console()
463
+ else utils::flush.console()
464
+ invisible(NULL)
465
+ }
466
+ if (!(INPUT_SHADOW %in% search())) {
467
+ # warn.conflicts = FALSE: masking `readline` (and `print`) is the entire
468
+ # point, and a startup warning about it would be printed into the user's
469
+ # first cell.
470
+ attach(list(readline = carmar_readline, print = carmar_print, flush.console = carmar_flush_console),
471
+ name = INPUT_SHADOW, warn.conflicts = FALSE)
472
+ }
473
+
474
+ #' A function's formals as one display string, defaults included.
475
+ #'
476
+ #' `formals()` is NULL for primitives like `sum`; `args()` still knows their
477
+ #' signature, so it is the fallback. The empty symbol deparses to "", which is
478
+ #' how an argument with no default is told apart from one whose default is "".
479
+ #'
480
+ #' @param f A function.
481
+ #' @return A single string, e.g. "x, y = 2, ...".
482
+ formals_string <- function(f) {
483
+ fl <- formals(f)
484
+ if (is.null(fl)) fl <- tryCatch(formals(args(f)), error = function(e) NULL)
485
+ if (is.null(fl) || length(fl) == 0L) return("")
486
+ paste(vapply(names(fl), function(nm) {
487
+ default <- paste(deparse(fl[[nm]]), collapse = " ")
488
+ if (nzchar(default)) paste(nm, "=", default) else nm
489
+ }, character(1)), collapse = ", ")
490
+ }
491
+
492
+ #' Describe every object in the global environment, for the Environment pane.
493
+ #'
494
+ #' `str()`-style one-liners rather than values: an IDE pane must stay cheap to
495
+ #' refresh after every cell, and a 2 GB matrix must not be serialised to say it
496
+ #' exists. Sizes come from object.size so the pane can show what is costing.
497
+ #'
498
+ #' @param id Request id, echoed back.
499
+ #' @return Invisibly NULL. Emits one `env` frame.
500
+ emit_env <- function(id) {
501
+ names_ <- ls(globalenv(), all.names = FALSE)
502
+ describe <- function(nm) {
503
+ v <- get(nm, envir = globalenv())
504
+ dims <- if (!is.null(dim(v))) paste(dim(v), collapse = " × ") else as.character(length(v))
505
+ summary_line <- if (is.data.frame(v)) {
506
+ sprintf("%d obs. of %d variable%s", nrow(v), ncol(v), if (ncol(v) == 1L) "" else "s")
507
+ } else if (is.function(v)) {
508
+ paste0("function(", paste(names(formals(v)), collapse = ", "), ")")
509
+ } else {
510
+ paste(utils::capture.output(utils::str(v, max.level = 0, give.attr = FALSE))[1L],
511
+ collapse = "")
512
+ }
513
+ # Three kinds because the pane treats them differently: "data" opens the
514
+ # viewer, "function" opens the inspector at its source, "value" just shows
515
+ # itself. Matrices count as data — cor(), table() and coef(summary()) are
516
+ # the most viewer-worthy things R produces (see matrix_to_df).
517
+ is_data <- is.data.frame(v) || is.matrix(v)
518
+ kind <- if (is.function(v)) "function" else if (is_data) "data" else "value"
519
+ base <- list(name = nm, class = class(v)[1L], dims = dims,
520
+ bytes = as.numeric(utils::object.size(v)), summary = summary_line,
521
+ kind = kind)
522
+ if (is_data) c(base, list(nrow = nrow(v), ncol = ncol(v)))
523
+ else if (is.function(v)) c(base, list(args = formals_string(v)))
524
+ else base
525
+ }
526
+ emit(list(type = "env", id = id,
527
+ objects = if (length(names_)) lapply(names_, describe) else list()))
528
+ }
529
+
530
+ #' Inspect one object — the pane an Environment row expands into.
531
+ #'
532
+ #' str() plus a capped print, never the value itself: an inspector's job is to
533
+ #' describe a 2 GB model without shipping it. `max.print` bounds what print()
534
+ #' GENERATES — capping capture.output afterwards would still pay to render a
535
+ #' 1e8-element vector first.
536
+ #'
537
+ #' @param id Request id.
538
+ #' @param name Name of an object visible from the global environment.
539
+ #' @param max_lines Cap applied separately to str, preview and source.
540
+ #' @return Invisibly NULL. Emits one `obj` frame.
541
+ emit_obj <- function(id, name = NULL, max_lines = 200L) {
542
+ # Wire-supplied fields get guards, not stopifnot: a malformed request must
543
+ # answer with an error field, never take the worker down mid-session.
544
+ if (!is.character(name) || length(name) != 1L || !nzchar(name)) {
545
+ emit(list(type = "obj", id = id, name = name, error = "bad name"))
546
+ return(invisible(NULL))
547
+ }
548
+ # Default inherits = TRUE, so package data (mtcars) inspects like the user's.
549
+ if (!exists(name, envir = globalenv())) {
550
+ emit(list(type = "obj", id = id, name = name, error = "not found"))
551
+ return(invisible(NULL))
552
+ }
553
+ v <- get(name, envir = globalenv())
554
+ capped <- function(lines) {
555
+ if (length(lines) > max_lines) {
556
+ lines <- c(lines[seq_len(max_lines)],
557
+ sprintf("... (%d more lines)", length(lines) - max_lines))
558
+ }
559
+ paste(lines, collapse = "\n")
560
+ }
561
+ old <- options(max.print = 2000L)
562
+ on.exit(options(old), add = TRUE)
563
+ frame <- list(
564
+ type = "obj", id = id, name = name, class = class(v)[1L],
565
+ str = capped(tryCatch(utils::capture.output(utils::str(v)),
566
+ error = function(e) conditionMessage(e))),
567
+ preview = capped(tryCatch(utils::capture.output(base::print(v)),
568
+ error = function(e) conditionMessage(e)))
569
+ )
570
+ if (is.function(v)) {
571
+ frame$formals <- formals_string(v)
572
+ frame$source <- capped(deparse(v))
573
+ }
574
+ emit(frame)
575
+ }
576
+
577
+ #' The child accessors a node exposes — the tree viewer's notion of "expandable".
578
+ #'
579
+ #' S4 slots, then list elements (data.frames are lists of columns). Everything
580
+ #' else — atomics, functions, calls, and above all ENVIRONMENTS — reports no
581
+ #' children: environments can be cyclic (an env holding itself, R6 objects), so
582
+ #' walking them turns a lazy tree into an infinite one.
583
+ #'
584
+ #' @param obj Any object.
585
+ #' @return Character vector of accessor tokens: names, or "[[i]]" for unnamed.
586
+ child_keys <- function(obj) {
587
+ if (isS4(obj)) return(methods::slotNames(class(obj)))
588
+ if (is.environment(obj) || !is.list(obj)) return(character(0))
589
+ nms <- names(obj)
590
+ if (is.null(nms)) nms <- character(length(obj))
591
+ ifelse(nzchar(nms), nms, sprintf("[[%d]]", seq_along(obj)))
592
+ }
593
+
594
+ #' Fetch one child by the token child_keys() produced for it.
595
+ #'
596
+ #' @param obj The parent node.
597
+ #' @param key A name, "[[i]]" index token, or S4 slot name.
598
+ #' @return The child value.
599
+ child_get <- function(obj, key) {
600
+ if (isS4(obj)) return(methods::slot(obj, key))
601
+ if (grepl("^\\[\\[\\d+\\]\\]$", key)) obj[[as.integer(gsub("\\D", "", key))]]
602
+ else obj[[key]]
603
+ }
604
+
605
+ #' One line that says what a node IS without shipping what it holds.
606
+ #'
607
+ #' @param v Any object.
608
+ #' @return A single short string.
609
+ struct_preview <- function(v) {
610
+ if (is.environment(v)) return("<environment>")
611
+ if (is.function(v)) return(paste0("function(", formals_string(v), ")"))
612
+ if (isS4(v)) return(paste0("S4 object of class ", class(v)[1L]))
613
+ if (is.data.frame(v) || is.matrix(v)) return(paste(dim(v), collapse = " × "))
614
+ if (is.list(v)) return(sprintf("list of %d", length(v)))
615
+ if (is.atomic(v) && length(v) > 0L) {
616
+ # head() before format(): formatting five elements of a 1e8 vector must
617
+ # not pay for the other 99,999,995.
618
+ shown <- format(utils::head(v, 5L), trim = TRUE)
619
+ return(paste0(paste(shown, collapse = ", "), if (length(v) > 5L) ", …" else ""))
620
+ }
621
+ if (is.atomic(v)) return(paste0(class(v)[1L], "(0)"))
622
+ class(v)[1L]
623
+ }
624
+
625
+ #' One level of an object's structure, for a lazily expanded tree viewer.
626
+ #'
627
+ #' The client sends back the `path` a child arrived with to expand that child;
628
+ #' nothing below the requested level is ever serialised, so an lm holding a
629
+ #' thousand-row model frame costs 13 child descriptors, not the frame.
630
+ #'
631
+ #' @param id Request id.
632
+ #' @param name Name of an object visible from the global environment.
633
+ #' @param path Character vector of accessor tokens; empty/NULL for the root.
634
+ #' @return Invisibly NULL. Emits one `struct` frame.
635
+ emit_struct <- function(id, name = NULL, path = NULL) {
636
+ if (!is.character(name) || length(name) != 1L || !nzchar(name)) {
637
+ emit(list(type = "struct", id = id, name = name, error = "bad name"))
638
+ return(invisible(NULL))
639
+ }
640
+ if (!exists(name, envir = globalenv())) {
641
+ emit(list(type = "struct", id = id, name = name, error = "not found"))
642
+ return(invisible(NULL))
643
+ }
644
+ keys <- if (is.null(path)) character(0) else as.character(unlist(path))
645
+ # Each step validates the token against child_keys first: `lst[["absent"]]`
646
+ # returns NULL rather than erroring, and a bad path must be an error frame,
647
+ # not a silent NULL node described with a straight face.
648
+ step <- function(o, k) {
649
+ if (is.environment(o)) stop("environments are not walked")
650
+ if (!(k %in% child_keys(o))) stop("no such element: ", k)
651
+ child_get(o, k)
652
+ }
653
+ node <- tryCatch(Reduce(step, keys, init = get(name, envir = globalenv())),
654
+ error = function(e) structure(class = "carmar_fail",
655
+ list(msg = conditionMessage(e))))
656
+ if (inherits(node, "carmar_fail")) {
657
+ emit(list(type = "struct", id = id, name = name, path = as.list(keys),
658
+ error = node$msg))
659
+ return(invisible(NULL))
660
+ }
661
+ kids <- child_keys(node)
662
+ describe_child <- function(k) {
663
+ v <- tryCatch(child_get(node, k), error = function(e) NULL)
664
+ list(name = k, path = as.list(c(keys, k)), class = class(v)[1L],
665
+ type = typeof(v), length = length(v),
666
+ size = as.numeric(utils::object.size(v)),
667
+ isLeaf = length(child_keys(v)) == 0L,
668
+ preview = struct_preview(v))
669
+ }
670
+ emit(list(type = "struct", id = id, name = name, path = as.list(keys),
671
+ class = class(node)[1L], type = typeof(node), length = length(node),
672
+ size = as.numeric(utils::object.size(node)),
673
+ preview = struct_preview(node),
674
+ children = lapply(utils::head(kids, MAX_STRUCT_CHILDREN), describe_child),
675
+ truncated = length(kids) > MAX_STRUCT_CHILDREN))
676
+ }
677
+
678
+ #' Describe one column the way a data viewer shows it: type, a sparkline's
679
+ #' worth of shape, a stat label, and how much is missing.
680
+ #'
681
+ #' The bins are computed HERE rather than shipping the column: a 500k-row
682
+ #' numeric vector is 4 MB of JSON and 12 counts is 60 bytes, and the viewer
683
+ #' only ever draws the 12.
684
+ #'
685
+ #' @param col A column vector.
686
+ #' @param name Column name.
687
+ #' @return A named list.
688
+ describe_column <- function(col, name) {
689
+ n <- length(col)
690
+ # ONE is.na pass. It was computed twice — once for the count, once to build
691
+ # `ok` — and on a million-row column that second pass is a wasted 4 MB
692
+ # logical and ~0.6 ms, paid for every column of every page.
693
+ na <- is.na(col)
694
+ missing <- sum(na)
695
+ base <- list(name = name, class = class(col)[1L], n = n, missing = missing)
696
+ # A SHORT summary for the hover panel, formatted here for the same reason the
697
+ # card's is (fmt_pct): R decides significant digits, and two formatters would
698
+ # eventually disagree about the same number. Deliberately not the card's list
699
+ # — hovering wants the six figures you would glance at, not fourteen.
700
+ pair <- function(label, value) list(label = label, value = value)
701
+ miss_pair <- if (missing > 0L) list(pair("Missing", fmt_share(missing, n))) else list()
702
+
703
+ # Every `bins`/`levels` below is wrapped in I(): auto_unbox turns a length-1
704
+ # vector into a bare scalar, so a constant column or single-level factor
705
+ # shipped `"bins": 10` and broke every client that mapped over it. I() pins
706
+ # the array shape regardless of length; scalars elsewhere stay scalars.
707
+ if (is.numeric(col)) {
708
+ ok <- col[!na]
709
+ if (length(ok) == 0L) return(c(base, list(kind = "numeric", bins = I(integer(0)),
710
+ stat = "all missing",
711
+ summary = I(list(pair("Missing",
712
+ fmt_share(missing, n)))))))
713
+ rng <- range(ok)
714
+ # tabulate(), not table(): table drops empty bins, so a gap in the data
715
+ # silently shortens the sparkline and every bar after it shifts left.
716
+ bins <- if (diff(rng) == 0) rep(length(ok), 1L) else
717
+ tabulate(cut(ok, breaks = 12L, labels = FALSE, include.lowest = TRUE), nbins = 12L)
718
+ med <- stats::median(ok)
719
+ # mean and sd measure at 0.1 and 0.2 ms on a million rows, against the
720
+ # 3.1 ms this function already costs — the cheapest useful thing to add.
721
+ mu <- mean(ok)
722
+ sigma <- stats::sd(ok)
723
+ c(base, list(kind = "numeric", bins = I(as.integer(bins)),
724
+ min = rng[1L], max = rng[2L], median = med, mean = mu, sd = sigma,
725
+ summary = I(c(list(pair("Mean", fmt_num(mu)),
726
+ pair("Std. dev.", fmt_num(sigma)),
727
+ pair("Minimum", fmt_num(rng[1L])),
728
+ pair("Median", fmt_num(med)),
729
+ pair("Maximum", fmt_num(rng[2L]))), miss_pair)),
730
+ stat = sprintf("%s – %s", fmt_num(rng[1L]), fmt_num(rng[2L]))))
731
+ } else if (is.logical(col)) {
732
+ yes <- sum(col %in% TRUE)
733
+ no <- sum(col %in% FALSE)
734
+ c(base, list(kind = "logical",
735
+ bins = I(as.integer(c(yes, no))),
736
+ levels = I(c("TRUE", "FALSE")),
737
+ summary = I(c(list(pair("TRUE", fmt_share(yes, yes + no)),
738
+ pair("FALSE", fmt_share(no, yes + no))), miss_pair)),
739
+ stat = sprintf("%d true / %d false", yes, no)))
740
+ } else {
741
+ text <- as.character(col[!na])
742
+ tab <- sort(table(text), decreasing = TRUE)
743
+ top <- utils::head(tab, 12L)
744
+ # The commonest levels answer "what is in here" far better than the level
745
+ # COUNT alone, and they are already computed for the sparkline. Four are
746
+ # listed when four is all there is, so a column with exactly four levels
747
+ # does not show three and silently swallow the last one.
748
+ lead <- utils::head(tab, if (length(tab) <= 4L) length(tab) else 3L)
749
+ tops <- lapply(seq_along(lead), function(i)
750
+ pair(substr(names(lead)[i], 1L, MAX_VIEW_LABEL_CHARS),
751
+ fmt_share(lead[[i]], length(text))))
752
+ c(base, list(kind = "categorical", bins = I(as.integer(top)),
753
+ levels = I(substr(as.character(names(top)), 1L, MAX_VIEW_LABEL_CHARS)),
754
+ nlevels = length(tab),
755
+ summary = I(c(list(pair("Distinct levels", fmt_count(length(tab)))),
756
+ tops, miss_pair)),
757
+ stat = sprintf("%d level%s", length(tab), if (length(tab) == 1L) "" else "s")))
758
+ }
759
+ }
760
+
761
+ fmt_num <- function(x) {
762
+ # as.character(NA_real_) is NA_character_, not "NA" — and a genuine NA in a
763
+ # display field ships as JSON null, which the card then prints as the word
764
+ # "null". A constant column's skewness is exactly this case (sd is 0, so the
765
+ # z-scores are 0/0), so it is a value the viewer really does meet.
766
+ if (is.nan(x)) return("NaN")
767
+ if (is.na(x)) return("NA")
768
+ if (!is.finite(x)) return(as.character(x))
769
+ if (abs(x) >= 1e5 || (abs(x) < 1e-3 && x != 0)) format(x, digits = 3, scientific = TRUE)
770
+ else format(round(x, 3), trim = TRUE)
771
+ }
772
+
773
+ fmt_count <- function(x) format(as.numeric(x), big.mark = ",", trim = TRUE, scientific = FALSE)
774
+
775
+ #' A share as a bare percentage: "85.3%".
776
+ #'
777
+ #' Every percentage on the statistics card comes from HERE, including the ones
778
+ #' beside each level in the bar list. R and JavaScript do not round halves the
779
+ #' same way — R's round() goes to even, so 50/4000 is 1.2%, while JavaScript's
780
+ #' toFixed(1) gives 1.3% — and a card that computed some of its own percentages
781
+ #' printed both, for the same count, two inches apart.
782
+ #' Always one decimal: format() drops a trailing zero, so a column of shares
783
+ #' read "1.2% / 1.1% / 1% / 1%" and the eye stopped trusting the alignment.
784
+ fmt_pct <- function(part, whole) {
785
+ if (!is.finite(whole) || whole <= 0) return("")
786
+ paste0(formatC(100 * part / whole, format = "f", digits = 1), "%")
787
+ }
788
+
789
+ #' A share as count and percentage: "3,412 (85.3%)".
790
+ fmt_share <- function(part, whole) {
791
+ if (!is.finite(whole) || whole <= 0) return(fmt_count(part))
792
+ sprintf("%s (%s)", fmt_count(part), fmt_pct(part, whole))
793
+ }
794
+
795
+ MAX_STATS_LEVELS <- 15L
796
+ STATS_BINS <- 24L
797
+
798
+ #' Everything a statistics card shows about ONE column.
799
+ #'
800
+ #' Deliberately separate from describe_column(): that one runs for every column
801
+ #' of every page and must stay cheap, so it ships 12 bins and a label. This one
802
+ #' runs when a reader asks about a single column and can afford quantiles, shape
803
+ #' moments and a level table.
804
+ #'
805
+ #' Numbers come back BOTH ways — as strings R formatted (so the card never
806
+ #' re-invents significant digits in JavaScript) and, where a drawing needs them,
807
+ #' as raw numerics. The two never disagree because they come from one value.
808
+ #'
809
+ #' @param col A column vector.
810
+ #' @param column_name Its name.
811
+ #' @return A named list; see the `colstats` frame.
812
+ colstats_payload <- function(col, column_name) {
813
+ n <- length(col)
814
+ missing <- sum(is.na(col))
815
+ ok <- col[!is.na(col)]
816
+ pair <- function(label, value) list(label = label, value = value)
817
+ base <- list(column = column_name, class = paste(class(col), collapse = "/"),
818
+ n = n, missing = missing, present = length(ok),
819
+ distinct = length(unique(ok)))
820
+
821
+ if (is.numeric(col) || inherits(col, "Date") || inherits(col, "POSIXct")) {
822
+ dated <- inherits(col, "Date") || inherits(col, "POSIXct")
823
+ x <- as.numeric(ok)
824
+ if (!length(x)) {
825
+ return(c(base, list(kind = if (dated) "date" else "numeric",
826
+ summary = I(list(pair("Present", "0 — every value is missing"))))))
827
+ }
828
+ show <- if (dated) function(v) format(if (inherits(col, "Date"))
829
+ as.Date(v, origin = "1970-01-01") else as.POSIXct(v, origin = "1970-01-01", tz = "UTC"))
830
+ else fmt_num
831
+ q <- stats::quantile(x, c(0.25, 0.5, 0.75), names = FALSE, type = 7)
832
+ iqr <- q[3L] - q[1L]
833
+ m <- mean(x)
834
+ s <- stats::sd(x)
835
+ # Fences are Tukey's, so "outlier" here means what a boxplot means by it —
836
+ # not a normal-theory z cut, which would be a different claim about data
837
+ # nobody has shown is normal.
838
+ lo_fence <- q[1L] - 1.5 * iqr
839
+ hi_fence <- q[3L] + 1.5 * iqr
840
+ outliers <- sum(x < lo_fence | x > hi_fence)
841
+ # Moments by hand rather than by dependency: both are one line, and
842
+ # e1071/moments are not worth a require() in a kernel that must boot fast.
843
+ z <- if (is.finite(s) && s > 0) (x - m) / s else rep(NA_real_, length(x))
844
+ skew <- if (all(is.finite(z))) mean(z^3) else NA_real_
845
+ kurt <- if (all(is.finite(z))) mean(z^4) - 3 else NA_real_
846
+ rng <- range(x)
847
+ bins <- if (diff(rng) == 0) as.integer(length(x)) else
848
+ tabulate(cut(x, breaks = STATS_BINS, labels = FALSE, include.lowest = TRUE),
849
+ nbins = STATS_BINS)
850
+ summary <- list(
851
+ pair("Mean", show(m)), pair("Std. dev.", if (dated) fmt_num(s) else fmt_num(s)),
852
+ pair("Minimum", show(rng[1L])), pair("1st quartile", show(q[1L])),
853
+ pair("Median", show(q[2L])), pair("3rd quartile", show(q[3L])),
854
+ pair("Maximum", show(rng[2L])), pair("IQR", if (dated) fmt_num(iqr) else fmt_num(iqr)),
855
+ pair("Median abs. dev.", fmt_num(stats::mad(x))),
856
+ pair("Skewness", fmt_num(skew)), pair("Excess kurtosis", fmt_num(kurt)),
857
+ pair("Outliers (1.5 IQR)", fmt_share(outliers, length(x))))
858
+ if (!dated) summary <- c(summary, list(
859
+ pair("Zeros", fmt_share(sum(x == 0), length(x))),
860
+ pair("Negative", fmt_share(sum(x < 0), length(x)))))
861
+ if (dated) summary <- c(summary, list(pair("Span", paste(fmt_num(diff(rng) /
862
+ if (inherits(col, "Date")) 1 else 86400), "days"))))
863
+ return(c(base, list(kind = if (dated) "date" else "numeric",
864
+ summary = I(summary),
865
+ bins = I(as.integer(bins)),
866
+ binMin = rng[1L], binMax = rng[2L],
867
+ box = list(min = rng[1L], q1 = q[1L], median = q[2L],
868
+ q3 = q[3L], max = rng[2L],
869
+ lower = max(lo_fence, rng[1L]),
870
+ upper = min(hi_fence, rng[2L]),
871
+ outliers = outliers))))
872
+ }
873
+
874
+ if (is.logical(col)) {
875
+ yes <- sum(col %in% TRUE)
876
+ no <- sum(col %in% FALSE)
877
+ return(c(base, list(kind = "logical",
878
+ summary = I(list(pair("TRUE", fmt_share(yes, yes + no)),
879
+ pair("FALSE", fmt_share(no, yes + no)),
880
+ pair("Missing", fmt_share(missing, n)))),
881
+ levels = I(c("TRUE", "FALSE")), counts = I(as.integer(c(yes, no))),
882
+ shares = I(c(fmt_pct(yes, yes + no), fmt_pct(no, yes + no))), other = 0L)))
883
+ }
884
+
885
+ text <- as_search_text(ok)
886
+ tab <- sort(table(text), decreasing = TRUE)
887
+ top <- utils::head(tab, MAX_STATS_LEVELS)
888
+ widths <- nchar(text)
889
+ summary <- list(
890
+ pair("Distinct levels", fmt_count(length(tab))),
891
+ pair("Most common", if (length(tab)) sprintf("%s — %s", names(tab)[1L],
892
+ fmt_share(tab[[1L]], length(text))) else "—"),
893
+ pair("Least common", if (length(tab)) sprintf("%s — %s", names(tab)[length(tab)],
894
+ fmt_share(tab[[length(tab)]], length(text))) else "—"),
895
+ pair("Empty strings", fmt_share(sum(!nzchar(text)), length(text))),
896
+ pair("Shortest", if (length(widths)) fmt_count(min(widths)) else "—"),
897
+ pair("Longest", if (length(widths)) fmt_count(max(widths)) else "—"),
898
+ pair("Mean length", if (length(widths)) fmt_num(mean(widths)) else "—"))
899
+ other <- as.integer(length(text) - sum(top))
900
+ c(base, list(kind = "categorical", summary = I(summary),
901
+ levels = I(substr(names(top), 1L, MAX_VIEW_LABEL_CHARS)),
902
+ counts = I(as.integer(top)),
903
+ shares = I(vapply(as.integer(top), fmt_pct, character(1),
904
+ whole = length(text))),
905
+ other = other, otherShare = fmt_pct(other, length(text)),
906
+ nlevels = length(tab)))
907
+ }
908
+
909
+ #' The statistics card: one column, profiled over the rows the grid is showing.
910
+ #'
911
+ #' Resolves the object exactly as emit_view does, and takes the SAME query and
912
+ #' filters, so the panel describes the visible data rather than a different
913
+ #' population that happens to share a name.
914
+ #'
915
+ #' @param id Request id.
916
+ #' @param name Object name or expression, as for `view`.
917
+ #' @param column The column to profile.
918
+ #' @param query,filters The viewer's active search and column filters.
919
+ emit_colstats <- function(id, name, column, query = NULL, filters = NULL) {
920
+ column <- if (is.character(column) && length(column)) column[[1L]] else ""
921
+ # Same console-read wedge as emit_view: parse(text = NULL) blocks the
922
+ # interactive worker on its own protocol stream.
923
+ if (!is.character(name) || length(name) != 1L || is.na(name) || !nzchar(name)) {
924
+ emit(list(type = "colstats", id = id, name = name, column = column,
925
+ error = "bad name"))
926
+ return(invisible(NULL))
927
+ }
928
+ obj <- tryCatch(eval(parse(text = name), globalenv()), error = function(e) NULL)
929
+ if (!is.data.frame(obj)) obj <- tryCatch(as.data.frame(obj, stringsAsFactors = FALSE),
930
+ error = function(e) NULL)
931
+ if (is.null(obj) || !nzchar(column)) {
932
+ emit(list(type = "colstats", id = id, name = name, column = column,
933
+ error = "not found"))
934
+ return(invisible(NULL))
935
+ }
936
+ total <- nrow(obj)
937
+ narrowed <- view_filter(obj, query, filters)
938
+ obj <- narrowed$obj
939
+ # The viewer truncates long column names for display; match on the truncated
940
+ # name too, or a card opened from a clipped header can never find its column.
941
+ hit <- match(column, names(obj))
942
+ if (is.na(hit)) hit <- match(column, substr(names(obj), 1L, MAX_VIEW_LABEL_CHARS))
943
+ if (is.na(hit)) {
944
+ emit(list(type = "colstats", id = id, name = name, column = column,
945
+ error = "no such column"))
946
+ return(invisible(NULL))
947
+ }
948
+ tryCatch(
949
+ emit(c(list(type = "colstats", id = id, name = name),
950
+ colstats_payload(obj[[hit]], column),
951
+ list(rows = nrow(obj), totalRows = total,
952
+ filtered = nzchar(narrowed$query) || narrowed$count > 0L))),
953
+ interrupt = function(i) emit(list(type = "colstats", id = id, name = name,
954
+ column = column, error = "interrupted"))
955
+ )
956
+ }
957
+
958
+ #' Autocomplete via R's own engine — the one behind TAB in the console,
959
+ #' RStudio and Jupyter. Not hand-rolled: the engine already understands `$`
960
+ #' and `@` access, argument names inside a call, `::` namespaces, library()
961
+ #' package names and quoted file paths, and hand-rolling any one of those
962
+ #' badly is worse than none.
963
+ #'
964
+ #' The five utils functions driving it are internal (:::), so the whole
965
+ #' pipeline is wrapped and degrades to an empty item list if a future R
966
+ #' renames them — a completion popup that is sometimes empty beats a worker
967
+ #' that dies on a keystroke.
968
+ #'
969
+ #' `start`/`end` are the 0-based character range of `line` the completion
970
+ #' REPLACES (`end` exclusive, = cursor): the engine completes "x$al" to the
971
+ #' full "x$alpha", so the client splices `line[0:start] + value + line[end:]`
972
+ #' and must never re-derive the token itself.
973
+ #'
974
+ #' @param id Request id.
975
+ #' @param line The source line being typed.
976
+ #' @param cursor Character offset of the caret in `line` (0-based).
977
+ #' @param max_items Cap on items shipped; `truncated` reports the cut.
978
+ #' @param fn Optional callee name (context the line alone cannot show); its
979
+ #' formals ride as `args`.
980
+ #' @param data Optional data-frame name in scope (pipe head, `data =`); its
981
+ #' columns ride as `columns: [{value, type}]`.
982
+ #' @return Invisibly NULL. Emits one `complete` frame. A frame with no items
983
+ #' carries `reason` when the engine actually FAILED, so a completion that has
984
+ #' gone dark can be told apart from a token R simply had nothing to say
985
+ #' about — the difference used to be invisible from the browser.
986
+ emit_complete <- function(id, line = NULL, cursor = NULL, max_items = MAX_COMPLETIONS,
987
+ fn = NULL, data = NULL) {
988
+ if (!is.character(line) || length(line) != 1L || is.na(line)) line <- ""
989
+ cursor <- max(0L, min(as_count(cursor, nchar(line)), nchar(line)))
990
+ empty <- list(type = "complete", id = id, start = cursor, end = cursor,
991
+ token = "", items = list(), truncated = FALSE,
992
+ args = I(completion_call_args(fn)),
993
+ columns = completion_columns(data, max_items))
994
+ # Degrading to an empty list keeps a keystroke from killing the worker, but
995
+ # a silent empty list is indistinguishable from "no matches" — and that is
996
+ # exactly the shape a broken completion engine takes in a long-lived
997
+ # session. The condition is kept and shipped with the empty frame.
998
+ reason <- NULL
999
+ st <- tryCatch({
1000
+ ce <- utils:::.CompletionEnv
1001
+ utils:::.assignLinebuffer(line)
1002
+ utils:::.assignEnd(cursor)
1003
+ utils:::.guessTokenFromLine()
1004
+ utils:::.completeToken()
1005
+ list(token = as.character(ce[["token"]]), start = as.integer(ce[["start"]]),
1006
+ comps = as.character(utils:::.retrieveCompletions()),
1007
+ quoted = isTRUE(ce[["fileName"]]))
1008
+ }, error = function(e) { reason <<- conditionMessage(e); NULL },
1009
+ interrupt = function(i) { reason <<- "interrupted"; NULL })
1010
+ if (is.null(st) || length(st$start) != 1L || is.na(st$start)) {
1011
+ if (is.null(reason) && !is.null(st)) {
1012
+ reason <- "the completion engine returned no position for this token"
1013
+ }
1014
+ if (!is.null(reason)) empty$reason <- as.character(reason)[[1L]]
1015
+ emit(empty)
1016
+ return(invisible(NULL))
1017
+ }
1018
+ # The engine's own fileName flag is not stable across versions (observed
1019
+ # FALSE on 4.5.2 while returning paths), so the quote immediately before
1020
+ # the token is the authoritative signal for path completion.
1021
+ quoted <- st$quoted ||
1022
+ (st$start > 0L && substr(line, st$start, st$start) %in% c("\"", "'"))
1023
+ in_library <- grepl(
1024
+ "(library|require|requireNamespace|loadNamespace)\\s*\\(\\s*[\"']?\\s*$",
1025
+ substr(line, 1L, st$start))
1026
+ is_name <- function(v) grepl("^[.a-zA-Z][._a-zA-Z0-9]*$", v)
1027
+ describe_item <- function(v) {
1028
+ if (endsWith(v, "=")) return(list(value = v, kind = "argument", detail = ""))
1029
+ if (endsWith(v, "::")) return(list(value = v, kind = "package", detail = ""))
1030
+ if (quoted) return(list(value = v, kind = "file", detail = ""))
1031
+ if (in_library) return(list(value = v, kind = "package", detail = ""))
1032
+ bare <- sub("\\($", "", v)
1033
+ if (bare %in% R_KEYWORDS) return(list(value = bare, kind = "keyword", detail = ""))
1034
+ if (endsWith(v, "(")) {
1035
+ # value ships WITHOUT the "(" — the editor decides about parentheses —
1036
+ # and the signature rides as the right-hand hint instead.
1037
+ f <- if (is_name(bare)) get0(bare, envir = globalenv(), mode = "function")
1038
+ else NULL
1039
+ return(list(value = bare, kind = "function",
1040
+ detail = if (is.function(f)) paste0("(", formals_string(f), ")")
1041
+ else ""))
1042
+ }
1043
+ detail <- ""
1044
+ if (is_name(v)) {
1045
+ o <- get0(v, envir = globalenv())
1046
+ if (!is.null(o)) {
1047
+ dims <- if (!is.null(dim(o))) paste(dim(o), collapse = " × ")
1048
+ else as.character(length(o))
1049
+ detail <- paste0(class(o)[1L], " · ", dims)
1050
+ }
1051
+ }
1052
+ list(value = v, kind = "variable", detail = detail)
1053
+ }
1054
+ frame <- list(type = "complete", id = id, start = st$start, end = cursor,
1055
+ token = st$token,
1056
+ items = lapply(utils::head(st$comps, max_items), describe_item),
1057
+ truncated = length(st$comps) > max_items)
1058
+ frame$args <- I(completion_call_args(fn))
1059
+ frame$columns <- completion_columns(data, max_items)
1060
+ emit(frame)
1061
+ }
1062
+
1063
+ #' A bare R name, or `pkg::name` — the only shape a completion context may
1064
+ #' name. Anything else (a call, a subset, an expression) is refused, because
1065
+ #' these names are looked up, and a lookup must never become an evaluation.
1066
+ #'
1067
+ #' @param x Anything the wire delivered.
1068
+ #' @param namespaced Whether `pkg::name` is accepted.
1069
+ #' @return TRUE when `x` is a single syntactic name.
1070
+ is_completion_name <- function(x, namespaced = FALSE) {
1071
+ pattern <- if (namespaced) "^([.A-Za-z][._A-Za-z0-9]*:::?)?[.A-Za-z][._A-Za-z0-9]*$"
1072
+ else "^[.A-Za-z][._A-Za-z0-9]*$"
1073
+ is.character(x) && length(x) == 1L && !is.na(x) && grepl(pattern, x)
1074
+ }
1075
+
1076
+ #' The argument names of the call the caret is inside, for a call split over
1077
+ #' lines — R's engine sees only the current line, so `lm(\n fo` would
1078
+ #' otherwise offer no arguments at all. Looked up with get0 in the global
1079
+ #' environment, or in an ALREADY LOADED namespace for `pkg::fn`; nothing is
1080
+ #' loaded or evaluated to answer.
1081
+ #'
1082
+ #' @param fn The callee's name, as the page's context detection found it.
1083
+ #' @return Character vector of formal names without `...`; empty when unknown.
1084
+ completion_call_args <- function(fn) {
1085
+ if (!is_completion_name(fn, namespaced = TRUE)) return(character(0))
1086
+ parts <- strsplit(fn, ":::?")[[1L]]
1087
+ f <- if (length(parts) == 2L) {
1088
+ if (parts[[1L]] %in% loadedNamespaces())
1089
+ get0(parts[[2L]], envir = asNamespace(parts[[1L]]), mode = "function")
1090
+ } else get0(fn, envir = globalenv(), mode = "function")
1091
+ if (!is.function(f)) return(character(0))
1092
+ fl <- formals(f)
1093
+ if (is.null(fl)) fl <- tryCatch(formals(args(f)), error = function(e) NULL)
1094
+ setdiff(names(fl), c("...", ""))
1095
+ }
1096
+
1097
+ #' The columns of the data frame a data-masking context names (a pipe head,
1098
+ #' `filter(df, `, `data = df`), typed like the Environment pane types them.
1099
+ #' A name that is not a data frame in the session yields nothing — an unrun
1100
+ #' chunk has no columns to offer, exactly as `df$` behaves.
1101
+ #'
1102
+ #' @param data The data frame's name.
1103
+ #' @param max_items Cap on columns shipped.
1104
+ #' @return A list of `{value, type}`; empty when there is no such data frame.
1105
+ completion_columns <- function(data, max_items = MAX_COMPLETIONS) {
1106
+ if (!is_completion_name(data)) return(list())
1107
+ df <- get0(data, envir = globalenv())
1108
+ if (!is.data.frame(df)) return(list())
1109
+ cols <- utils::head(names(df), max_items)
1110
+ lapply(cols, function(nm) list(value = nm, type = class(df[[nm]])[1L]))
1111
+ }
1112
+
1113
+ #' Coerce a wire-supplied count, falling back rather than erroring: a malformed
1114
+ #' offset from a client must degrade to the default, not kill the pane.
1115
+ #'
1116
+ #' @param x Anything the wire delivered.
1117
+ #' @param default Used when x is absent or not a number.
1118
+ #' @return A single integer.
1119
+ as_count <- function(x, default) {
1120
+ n <- suppressWarnings(as.integer(x))
1121
+ if (length(n) != 1L || is.na(n)) default else n
1122
+ }
1123
+
1124
+ #' Assemble the body of a view reply: true shape, whole-column descriptions,
1125
+ #' and one row × column window. Shared by `view` and `import`, so the two
1126
+ #' frames stay the same shape — and the same CAPS — by construction rather
1127
+ #' than by discipline.
1128
+ #'
1129
+ #' A column as searchable text, whatever it holds.
1130
+ as_search_text <- function(col) {
1131
+ tryCatch(as.character(col), error = function(e) rep("", length(col)))
1132
+ }
1133
+
1134
+ # `grepl(..., fixed = TRUE, ignore.case = TRUE)` is NOT case-insensitive:
1135
+ # R ignores ignore.case whenever fixed is TRUE, and says so in a warning
1136
+ # that goes nowhere anyone reads. The viewer's search box and its text
1137
+ # column filters all promised case-insensitive matching in their own
1138
+ # documentation and all silently required the exact case — searching
1139
+ # "Cohesion" found nothing in a column of "cohesion".
1140
+ #
1141
+ # So the needle is escaped to a literal pattern instead and `fixed` is
1142
+ # dropped, which keeps every metacharacter inert (`a.c` matches only "a.c",
1143
+ # `f[1]` does not blow up) while letting PCRE fold case, including for
1144
+ # non-ASCII: "É" matches "é".
1145
+ escape_regex <- function(s) gsub("([][{}()*+?.^$|\\\\])", "\\\\\\1", s)
1146
+ contains_ci <- function(text, needle) {
1147
+ grepl(escape_regex(needle), text, ignore.case = TRUE, perl = TRUE)
1148
+ }
1149
+
1150
+ #' One column filter spec → a logical keep-mask. Never evaluates the spec:
1151
+ #' comparisons and ranges are parsed, everything else is literal text.
1152
+ #'
1153
+ #' @param col A column vector.
1154
+ #' @param spec The user's filter string.
1155
+ #' @return A logical vector as long as `col`.
1156
+ match_filter <- function(col, spec) {
1157
+ spec <- trimws(as.character(spec)[1L])
1158
+ if (!nzchar(spec)) return(rep(TRUE, length(col)))
1159
+ if (identical(toupper(spec), "NA")) return(is.na(col))
1160
+ if (is.logical(col)) {
1161
+ wanted <- toupper(spec)
1162
+ if (wanted %in% c("TRUE", "T")) return(!is.na(col) & col)
1163
+ if (wanted %in% c("FALSE", "F")) return(!is.na(col) & !col)
1164
+ }
1165
+ if (is.numeric(col)) {
1166
+ range <- strsplit(spec, "\\.\\.", perl = TRUE)[[1L]]
1167
+ if (length(range) == 2L) {
1168
+ lo <- suppressWarnings(as.numeric(trimws(range[1L])))
1169
+ hi <- suppressWarnings(as.numeric(trimws(range[2L])))
1170
+ if (!is.na(lo) && !is.na(hi)) return(!is.na(col) & col >= lo & col <= hi)
1171
+ }
1172
+ parts <- regmatches(spec, regexec("^\\s*(>=|<=|!=|==|=|>|<)\\s*(-?[0-9]+(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)\\s*$", spec))[[1L]]
1173
+ if (length(parts) == 3L) {
1174
+ target <- as.numeric(parts[3L])
1175
+ hit <- switch(parts[2L], ">=" = col >= target, "<=" = col <= target,
1176
+ "!=" = col != target, "==" = col == target, "=" = col == target,
1177
+ ">" = col > target, "<" = col < target)
1178
+ return(!is.na(hit) & hit)
1179
+ }
1180
+ target <- suppressWarnings(as.numeric(spec))
1181
+ if (!is.na(target)) return(!is.na(col) & col == target)
1182
+ }
1183
+ text <- as_search_text(col)
1184
+ if (startsWith(spec, "=")) return(!is.na(col) & tolower(text) == tolower(substring(spec, 2L)))
1185
+ if (startsWith(spec, "!")) return(is.na(col) | !contains_ci(text, substring(spec, 2L)))
1186
+ !is.na(col) & contains_ci(text, spec)
1187
+ }
1188
+
1189
+ #' Narrow a frame by the viewer's search box and its per-column filters.
1190
+ #'
1191
+ #' Extracted from view_payload so `colstats` can answer about exactly the rows
1192
+ #' the grid is displaying. A statistics panel that quietly profiled the whole
1193
+ #' frame while the grid showed a filtered subset would be worse than no panel:
1194
+ #' both numbers look authoritative and only one answers the question asked.
1195
+ #'
1196
+ #' @param obj A data.frame.
1197
+ #' @param query Free-text search across every column.
1198
+ #' @param filters Named list of per-column filter specs.
1199
+ #' @return list(obj, count = filters applied, query = the trimmed query).
1200
+ view_filter <- function(obj, query = NULL, filters = NULL) {
1201
+ count <- 0L
1202
+ if (is.list(filters) && length(filters) && length(names(filters))) {
1203
+ for (nm in intersect(names(filters), names(obj))) {
1204
+ spec <- filters[[nm]]
1205
+ if (length(spec) && nzchar(trimws(as.character(spec)[1L]))) {
1206
+ obj <- obj[match_filter(obj[[nm]], spec), , drop = FALSE]
1207
+ count <- count + 1L
1208
+ }
1209
+ }
1210
+ }
1211
+ query <- if (is.character(query) && length(query)) trimws(query[1L]) else ""
1212
+ if (nzchar(query) && nrow(obj)) {
1213
+ hits <- lapply(obj, function(col) contains_ci(as_search_text(col), query))
1214
+ keep <- Reduce(`|`, hits, init = rep(FALSE, nrow(obj)))
1215
+ obj <- obj[keep, , drop = FALSE]
1216
+ }
1217
+ list(obj = obj, count = count, query = query)
1218
+ }
1219
+
1220
+ #' Sorting happens HERE, not in the client: the client only ever holds one
1221
+ #' page, so a client-side sort would order 200 rows of a million-row frame and
1222
+ #' call it sorted. It runs on the full frame BEFORE any windowing, so a column
1223
+ #' window still sees globally ordered rows.
1224
+ #'
1225
+ #' The reply always states both what exists (`nrow`/`ncol`) and what it
1226
+ #' actually contains (`shown`/`shownCols`, effective `limit`/`colLimit`,
1227
+ #' clamp flags) — nothing downstream should have to guess.
1228
+ #'
1229
+ #' @param obj Any object; coerced to data.frame or described as "not a table".
1230
+ #' @param shown_name Display name echoed in the frame.
1231
+ #' @param offset Rows to skip before the page (default 0).
1232
+ #' @param limit Page size (default 200, hard-capped at MAX_VIEW_ROWS).
1233
+ #' @param sort Column name to order by; unknown names are ignored.
1234
+ #' @param desc Descending order when TRUE.
1235
+ #' @param col_offset Columns to skip before the window (default 0).
1236
+ #' @param col_limit Column window size (default 30, capped at MAX_VIEW_COLS).
1237
+ #' @param query Case-insensitive text searched across every column.
1238
+ #' @param filters Named column filter strings. Numeric comparisons and ranges
1239
+ #' are interpreted without eval; all other values use text matching.
1240
+ #' @return Named list of frame fields (no type/id — the caller owns those).
1241
+ view_payload <- function(obj, shown_name, offset = NULL, limit = NULL,
1242
+ sort = NULL, desc = FALSE,
1243
+ col_offset = NULL, col_limit = NULL,
1244
+ query = NULL, filters = NULL) {
1245
+ if (!is.data.frame(obj)) obj <- tryCatch(as.data.frame(obj, stringsAsFactors = FALSE),
1246
+ error = function(e) NULL)
1247
+ if (is.null(obj)) return(list(name = shown_name, error = "not a table"))
1248
+ total_rows <- nrow(obj)
1249
+ offset <- max(0L, as_count(offset, 0L))
1250
+ limit_req <- max(1L, as_count(limit, 200L))
1251
+ limit <- min(limit_req, MAX_VIEW_ROWS)
1252
+ col_offset <- max(0L, as_count(col_offset, 0L))
1253
+ col_limit_req <- max(1L, as_count(col_limit, 30L))
1254
+ col_limit <- min(col_limit_req, MAX_VIEW_COLS)
1255
+
1256
+ # Search and per-column filters. Shared with the statistics card, so a
1257
+ # profile of "the rows you are looking at" cannot disagree with the rows the
1258
+ # grid is actually showing.
1259
+ narrowed <- view_filter(obj, query, filters)
1260
+ obj <- narrowed$obj
1261
+ filter_count <- narrowed$count
1262
+ query <- narrowed$query
1263
+ if (is.character(sort) && length(sort) == 1L && sort %in% names(obj)) {
1264
+ obj <- obj[order(obj[[sort]], decreasing = isTRUE(desc)), , drop = FALSE]
1265
+ }
1266
+ cidx <- seq.int(from = col_offset + 1L,
1267
+ length.out = max(0L, min(col_limit, ncol(obj) - col_offset)))
1268
+ idx <- seq.int(from = offset + 1L,
1269
+ length.out = max(0L, min(limit, nrow(obj) - offset)))
1270
+ page <- obj[idx, cidx, drop = FALSE]
1271
+ source_names <- names(obj)[cidx]
1272
+ display_names <- make.unique(substr(source_names, 1L, MAX_VIEW_LABEL_CHARS))
1273
+ names(page) <- display_names
1274
+ # Row names would ride along as a `_row` field in the JSON; the offset
1275
+ # already says where the page sits, so they are noise.
1276
+ rownames(page) <- NULL
1277
+ # A single cell can dwarf the row/column caps (logs, embedded documents,
1278
+ # accidental blobs). The viewer is a preview, so clip display values before
1279
+ # JSON encoding. This also gives a hard upper bound when the page has only
1280
+ # one row and row shedding cannot help.
1281
+ clipped_cells <- 0L
1282
+ page[] <- lapply(page, function(col) {
1283
+ if (is.factor(col)) col <- as.character(col)
1284
+ if (is.list(col) && !is.data.frame(col)) {
1285
+ col <- vapply(col, function(value) {
1286
+ tryCatch(as.character(jsonlite::toJSON(value, auto_unbox = TRUE,
1287
+ null = "null", na = "null")),
1288
+ error = function(e) paste(capture.output(str(value,
1289
+ max.level = 1L, give.attr = FALSE)), collapse = " "))
1290
+ }, character(1))
1291
+ }
1292
+ if (is.character(col)) {
1293
+ too_long <- !is.na(col) & nchar(col) > MAX_VIEW_CELL_CHARS
1294
+ clipped_cells <<- clipped_cells + sum(too_long)
1295
+ col[too_long] <- paste0(substr(col[too_long], 1L, MAX_VIEW_CELL_CHARS), "…")
1296
+ }
1297
+ col
1298
+ })
1299
+ # Payload guard: the caps above bound CELLS, not bytes — 500 rows of 20 KB
1300
+ # strings is still a 10 MB frame that would stall the socket. Measure the
1301
+ # page as it will actually ship and shed rows until it fits, reporting the
1302
+ # shrunken window rather than silently serving it.
1303
+ page_json <- jsonlite::toJSON(page, auto_unbox = TRUE, null = "null",
1304
+ na = "null", digits = NA)
1305
+ bytes <- nchar(page_json, type = "bytes")
1306
+ if (bytes > MAX_VIEW_BYTES && nrow(page) > 1L) {
1307
+ keep <- max(1L, as.integer(floor(MAX_VIEW_BYTES / (bytes / nrow(page)))))
1308
+ if (keep < nrow(page)) {
1309
+ page <- page[seq_len(keep), , drop = FALSE]
1310
+ limit <- keep
1311
+ }
1312
+ }
1313
+ # `columns` describes the windowed SET only, but each description covers the
1314
+ # WHOLE column — the sparkline must show the full distribution, not a page's.
1315
+ list(name = shown_name, nrow = nrow(obj), ncol = ncol(obj), totalRows = total_rows,
1316
+ filtered = nzchar(query) || filter_count > 0L, filterCount = filter_count,
1317
+ offset = offset, limit = limit,
1318
+ colOffset = col_offset, colLimit = col_limit,
1319
+ limitClamped = limit < limit_req,
1320
+ colLimitClamped = col_limit < col_limit_req,
1321
+ clippedCells = clipped_cells,
1322
+ columns = Map(function(index, display)
1323
+ describe_column(obj[[index]], display), cidx, display_names),
1324
+ rows = page, shown = nrow(page), shownCols = ncol(page))
1325
+ }
1326
+
1327
+ #' The data viewer: column descriptions plus one row × column window.
1328
+ #'
1329
+ #' @param id Request id.
1330
+ #' @param name Name of an object in the global environment, or an expression.
1331
+ #' @param offset,limit,sort,desc,col_offset,col_limit Windowing and ordering —
1332
+ #' see view_payload.
1333
+ #' @param label Display name when it differs from the fetch name (View()).
1334
+ emit_view <- function(id, name, offset = NULL, limit = NULL, sort = NULL,
1335
+ desc = FALSE, col_offset = NULL, col_limit = NULL,
1336
+ label = NULL, query = NULL, filters = NULL) {
1337
+ # `parse(text = NULL)` falls back to `parse(file = "")` — the CONSOLE. In
1338
+ # the interactive worker that read BLOCKS on the protocol stream and wedges
1339
+ # the whole session (a batch Rscript merely got instant EOF, which is why
1340
+ # this guard did not exist from day one). Wire-supplied, so an error frame,
1341
+ # never a stop.
1342
+ if (!is.character(name) || length(name) != 1L || is.na(name) || !nzchar(name)) {
1343
+ emit(list(type = "view", id = id, name = label %||% name, error = "bad name"))
1344
+ return(invisible(NULL))
1345
+ }
1346
+ shown <- if (is.null(label)) name else label
1347
+ obj <- tryCatch(eval(parse(text = name), globalenv()), error = function(e) NULL)
1348
+ if (is.null(obj)) {
1349
+ emit(list(type = "view", id = id, name = shown, error = "not found"))
1350
+ return(invisible(NULL))
1351
+ }
1352
+ # A Stop pressed mid-sort of a big frame must cancel the page, not the worker.
1353
+ tryCatch(
1354
+ emit(c(list(type = "view", id = id),
1355
+ view_payload(obj, shown, offset, limit, sort, desc,
1356
+ col_offset, col_limit, query, filters))),
1357
+ interrupt = function(i) emit(list(type = "view", id = id, name = shown,
1358
+ error = "interrupted"))
1359
+ )
1360
+ }
1361
+
1362
+
1363
+ #' Describe a file WITHOUT reading it into the session.
1364
+ #'
1365
+ #' The import wizard's eyes. Answers with the detected format, the delimited
1366
+ #' settings, a per-column type guess (including the date format, the timezone
1367
+ #' and any day/month ambiguity) and a text preview — but assigns nothing and
1368
+ #' changes nothing. Inspecting a file the user has not yet agreed to import
1369
+ #' must not put an object in their environment, and re-inspecting it under
1370
+ #' different settings must not put twenty.
1371
+ #'
1372
+ #' Every failure is an `error` FIELD rather than a thrown condition: pointing
1373
+ #' the wizard at a 2 GB binary is a normal thing to do by accident, and it
1374
+ #' must cost a message, not the session.
1375
+ #'
1376
+ #' @param id Request id.
1377
+ #' @param path File to inspect; `~` is expanded.
1378
+ #' @param opts Overrides from the wizard (delim, quote, encoding, header,
1379
+ #' skip, sheet, naStrings, tz). Anything absent is detected.
1380
+ #' @return Invisibly NULL. Emits one `sniff` frame.
1381
+ emit_sniff <- function(id, path = NULL, opts = NULL) {
1382
+ fail <- function(msg) {
1383
+ emit(list(type = "sniff", id = id, path = path, error = msg))
1384
+ invisible(NULL)
1385
+ }
1386
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
1387
+ # A URL is a source too. The confinement root governs the FILESYSTEM, and a
1388
+ # remote read touches none of it — but a confined deployment is confined on
1389
+ # purpose, so remote sources are refused there rather than quietly allowed.
1390
+ remote <- exists("is_url", inherits = TRUE) && is_url(path)
1391
+ if (remote) {
1392
+ if (!is.null(confine_root)) return(fail("remote sources are disabled in this deployment"))
1393
+ p <- path
1394
+ } else {
1395
+ p <- path.expand(path)
1396
+ if (!within_root(p)) return(fail(outside_root_msg()))
1397
+ if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
1398
+ }
1399
+ if (!exists("sniff_file", inherits = TRUE)) {
1400
+ return(fail("this kernel is too old for the import wizard - restart CarmaR"))
1401
+ }
1402
+ res <- tryCatch(
1403
+ sniff_file(p, if (is.list(opts)) opts else list()),
1404
+ error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
1405
+ interrupt = function(i) structure(class = "carmar_fail", list(msg = "interrupted"))
1406
+ )
1407
+ if (inherits(res, "carmar_fail")) return(fail(res$msg))
1408
+ emit(c(list(type = "sniff", id = id), res))
1409
+ }
1410
+
1411
+ #' Read a file into the session and answer with a first view of it.
1412
+ #'
1413
+ #' rio when installed (one verb, ~30 formats); otherwise the extension picks a
1414
+ #' base/readxl reader. Every failure — missing file, bad format, absent readxl,
1415
+ #' a Stop mid-read — comes back as an `error` field, because a bad file must
1416
+ #' never cost the session.
1417
+ #'
1418
+ #' @param id Request id.
1419
+ #' @param path File to read; `~` is expanded.
1420
+ #' @param name Name to assign; defaults to the file name, made syntactic.
1421
+ #' @return Invisibly NULL. Emits one `import` frame.
1422
+ emit_import <- function(id, path = NULL, name = NULL) {
1423
+ fail <- function(msg) {
1424
+ emit(list(type = "import", id = id, path = path, error = msg))
1425
+ invisible(NULL)
1426
+ }
1427
+ if (!is.character(path) || length(path) != 1L || !nzchar(path)) return(fail("no path"))
1428
+ p <- path.expand(path)
1429
+ if (!within_root(p)) return(fail(outside_root_msg()))
1430
+ if (!file.exists(p) || dir.exists(p)) return(fail("not a readable file"))
1431
+ read_by_ext <- function(f) {
1432
+ ext <- tolower(tools::file_ext(f))
1433
+ if (ext %in% c("xls", "xlsx")) {
1434
+ if (!requireNamespace("readxl", quietly = TRUE)) stop("readxl is not installed")
1435
+ return(as.data.frame(readxl::read_excel(f)))
1436
+ }
1437
+ switch(ext,
1438
+ csv = utils::read.csv(f, stringsAsFactors = FALSE),
1439
+ tsv = utils::read.delim(f, stringsAsFactors = FALSE),
1440
+ txt = utils::read.delim(f, stringsAsFactors = FALSE),
1441
+ rds = readRDS(f),
1442
+ stop("unsupported file type: .", ext))
1443
+ }
1444
+ obj <- tryCatch(
1445
+ if (requireNamespace("rio", quietly = TRUE)) rio::import(p) else read_by_ext(p),
1446
+ error = function(e) structure(class = "carmar_fail", list(msg = conditionMessage(e))),
1447
+ interrupt = function(i) structure(class = "carmar_fail", list(msg = "interrupted"))
1448
+ )
1449
+ if (inherits(obj, "carmar_fail")) return(fail(obj$msg))
1450
+ nm <- if (is.character(name) && length(name) == 1L && nzchar(name)) name
1451
+ else make.names(tools::file_path_sans_ext(basename(p)))
1452
+ assign(nm, obj, envir = globalenv())
1453
+ # The reply is view-shaped so the client can open the viewer straight from
1454
+ # it. An RDS holding a model still assigns; the payload then says "not a
1455
+ # table" and the obj inspector is the right next stop.
1456
+ emit(c(list(type = "import", id = id, assigned = nm, path = p),
1457
+ view_payload(obj, nm)))
1458
+ }
1459
+
1460
+ #' Remove objects — the Environment pane's broom, and single-object delete.
1461
+ #'
1462
+ #' @param id Request id.
1463
+ #' @param names Character vector, or NULL for everything.
1464
+ emit_rm <- function(id, names = NULL) {
1465
+ target <- if (is.null(names) || !length(names)) ls(globalenv(), all.names = TRUE) else names
1466
+ removed <- intersect(target, ls(globalenv(), all.names = TRUE))
1467
+ if (length(removed)) rm(list = removed, envir = globalenv())
1468
+ emit(list(type = "removed", id = id, names = as.list(removed),
1469
+ remaining = length(ls(globalenv(), all.names = FALSE))))
1470
+ }
1471
+
1472
+ #' Is this source a complete R expression?
1473
+ #'
1474
+ #' The console needs R's own parser to decide, not a brace counter: `f(1,` and
1475
+ #' `"unterminated` and `x +` are all incomplete for different reasons, and only
1476
+ #' the parser knows which. Drives the continuation prompt.
1477
+ #'
1478
+ #' @param id Request id.
1479
+ #' @param source Source text.
1480
+ #' @return Invisibly NULL. Emits one `parse` frame.
1481
+ emit_parse <- function(id, source) {
1482
+ # NULL would send parse() to the console — see the guard in emit_view.
1483
+ if (!is.character(source) || length(source) != 1L || is.na(source)) source <- ""
1484
+ complete <- TRUE
1485
+ message_ <- NULL
1486
+ tryCatch(
1487
+ parse(text = source),
1488
+ error = function(e) {
1489
+ msg <- conditionMessage(e)
1490
+ # R says "unexpected end of input" only when more input would help.
1491
+ complete <<- !grepl("unexpected end of input|unexpected INCOMPLETE_STRING", msg)
1492
+ message_ <<- msg
1493
+ }
1494
+ )
1495
+ emit(list(type = "parse", id = id, complete = complete, message = message_))
1496
+ }
1497
+
1498
+ #' Reformat R source with a real formatter, if the session has one.
1499
+ #'
1500
+ #' "Beautify" through a language model is a guess dressed as a tool: a cautious
1501
+ #' model hands back what you gave it, which reads as the feature being broken.
1502
+ #' Formatting is a solved, deterministic problem — styler does it exactly, for
1503
+ #' free, offline. The source arrives as a JSON string, so nothing is escaped
1504
+ #' into a literal and no code is constructed from text.
1505
+ #'
1506
+ #' `available = FALSE` is not an error: it tells the caller to fall back.
1507
+ emit_format <- function(id, source) {
1508
+ txt <- if (is.character(source)) paste(source, collapse = "\n") else ""
1509
+ lines <- strsplit(txt, "\n", fixed = TRUE)[[1]]
1510
+ engine <- if (requireNamespace("styler", quietly = TRUE)) "styler"
1511
+ else if (requireNamespace("formatR", quietly = TRUE)) "formatR"
1512
+ else NA_character_
1513
+ if (is.na(engine)) {
1514
+ emit(list(type = "format", id = id, available = FALSE))
1515
+ return(invisible(NULL))
1516
+ }
1517
+ out <- tryCatch({
1518
+ if (identical(engine, "styler")) {
1519
+ paste(as.character(styler::style_text(lines)), collapse = "\n")
1520
+ } else {
1521
+ paste(formatR::tidy_source(text = lines, output = FALSE, comment = TRUE,
1522
+ arrow = TRUE, width.cutoff = 80L)$text.tidy,
1523
+ collapse = "\n")
1524
+ }
1525
+ }, error = function(e) NULL)
1526
+ if (is.null(out)) {
1527
+ # Unparseable code is the user's, not the formatter's, problem to report.
1528
+ emit(list(type = "format", id = id, available = TRUE, engine = engine,
1529
+ error = "this code could not be parsed, so it was left alone"))
1530
+ } else {
1531
+ emit(list(type = "format", id = id, available = TRUE, engine = engine, text = out))
1532
+ }
1533
+ }
1534
+
1535
+ #' Loaded namespaces by default; the larger installed inventory is opt-in.
1536
+ emit_packages <- function(id, scope = "loaded") {
1537
+ scope <- if (is.character(scope) && length(scope) == 1L &&
1538
+ identical(scope, "installed")) "installed" else "loaded"
1539
+ loaded <- loadedNamespaces()
1540
+ attached <- sub("^package:", "", grep("^package:", search(), value = TRUE))
1541
+ rss_mb <- tryCatch({
1542
+ if (!identical(.Platform$OS.type, "unix")) NA_real_ else {
1543
+ kb <- suppressWarnings(as.numeric(trimws(system2(
1544
+ "ps", c("-o", "rss=", "-p", as.character(Sys.getpid())),
1545
+ stdout = TRUE, stderr = FALSE
1546
+ ))[1L]))
1547
+ if (is.finite(kb)) round(kb / 1024, 1L) else NA_real_
1548
+ }
1549
+ }, error = function(e) NA_real_)
1550
+ record <- function(name, version = NULL, lib = NULL, priority = NULL) {
1551
+ if (is.null(lib)) {
1552
+ path <- find.package(name, quiet = TRUE)
1553
+ lib <- if (nzchar(path)) dirname(path) else ""
1554
+ }
1555
+ if (is.null(version)) {
1556
+ version <- tryCatch(as.character(utils::packageVersion(name)), error = function(e) "")
1557
+ }
1558
+ if (is.null(priority)) {
1559
+ priority <- tryCatch(utils::packageDescription(name, fields = "Priority"),
1560
+ error = function(e) "")
1561
+ }
1562
+ priority <- if (length(priority) && !is.na(priority[1L])) as.character(priority[1L]) else ""
1563
+ lib_norm <- normalizePath(lib, winslash = "/", mustWork = FALSE)
1564
+ protected <- identical(priority, "base")
1565
+ is_attached <- name %in% attached
1566
+ list(name = name, version = as.character(version), lib = lib,
1567
+ loaded = is_attached, attached = is_attached,
1568
+ namespaceLoaded = name %in% loaded,
1569
+ writable = nzchar(lib) && file.access(lib, 2L) == 0L,
1570
+ protected = protected, priority = priority)
1571
+ }
1572
+ if (identical(scope, "installed")) {
1573
+ inst <- utils::installed.packages()[, c("Package", "Version", "LibPath", "Priority"), drop = FALSE]
1574
+ ord <- order(!inst[, "Package"] %in% attached,
1575
+ !inst[, "Package"] %in% loaded,
1576
+ tolower(inst[, "Package"]))
1577
+ inst <- inst[ord, , drop = FALSE]
1578
+ packages <- lapply(seq_len(nrow(inst)), function(i) record(
1579
+ unname(inst[i, "Package"]), unname(inst[i, "Version"]),
1580
+ unname(inst[i, "LibPath"]), unname(inst[i, "Priority"])
1581
+ ))
1582
+ } else {
1583
+ loaded <- loaded[order(!loaded %in% attached, tolower(loaded))]
1584
+ packages <- lapply(loaded, record)
1585
+ }
1586
+ emit(list(type = "packages", id = id, scope = scope,
1587
+ memoryMb = rss_mb,
1588
+ packages = packages))
1589
+ }
1590
+
1591
+ #' Apply one explicit package operation and report its result to the pane.
1592
+ emit_package_action <- function(id, action, name, lib = NULL) {
1593
+ valid_name <- is.character(name) && length(name) == 1L &&
1594
+ grepl("^[A-Za-z][A-Za-z0-9.]*$", name)
1595
+ if (!valid_name) {
1596
+ emit(list(type = "package_action", id = id, error = "invalid package name"))
1597
+ return(invisible(NULL))
1598
+ }
1599
+ action <- if (is.character(action) && length(action) == 1L) action else ""
1600
+ allowed <- c("load", "attach", "detach", "unload", "install", "update", "remove")
1601
+ if (!action %in% allowed) {
1602
+ emit(list(type = "package_action", id = id, error = "unknown package action"))
1603
+ return(invisible(NULL))
1604
+ }
1605
+
1606
+ perform <- function() {
1607
+ attached <- sub("^package:", "", grep("^package:", search(), value = TRUE))
1608
+ package_path <- find.package(name, quiet = TRUE)
1609
+ installed <- length(package_path) == 1L && nzchar(package_path)
1610
+ target_lib <- if (installed) dirname(package_path) else ""
1611
+ if (is.character(lib) && length(lib) == 1L && nzchar(lib)) {
1612
+ requested <- normalizePath(lib, winslash = "/", mustWork = FALSE)
1613
+ known <- normalizePath(.libPaths(), winslash = "/", mustWork = FALSE)
1614
+ if (!requested %in% known) stop("the selected package library is not active in this R session")
1615
+ target_lib <- lib
1616
+ }
1617
+ priority <- if (installed) tryCatch(
1618
+ utils::packageDescription(name, lib.loc = target_lib, fields = "Priority"),
1619
+ error = function(e) "") else ""
1620
+ protected <- installed && identical(unname(priority), "base")
1621
+
1622
+ if (action %in% c("detach", "unload", "update", "remove") && protected) {
1623
+ stop("R system packages are protected")
1624
+ }
1625
+ if (identical(action, "load")) {
1626
+ loadNamespace(name)
1627
+ return(paste(name, "namespace loaded"))
1628
+ }
1629
+ if (identical(action, "attach")) {
1630
+ suppressPackageStartupMessages(library(name, character.only = TRUE))
1631
+ return(paste(name, "attached to the search path"))
1632
+ }
1633
+ if (identical(action, "detach")) {
1634
+ if (!name %in% attached) return(paste(name, "is not attached"))
1635
+ detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
1636
+ return(paste(name, "detached; its namespace remains loaded"))
1637
+ }
1638
+ if (identical(action, "unload")) {
1639
+ was_attached <- name %in% attached
1640
+ if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
1641
+ if (!name %in% loadedNamespaces()) return(paste(name, "namespace is not loaded"))
1642
+ tryCatch(unloadNamespace(name), error = function(e) {
1643
+ if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
1644
+ stop(e)
1645
+ })
1646
+ return(paste(name, if (was_attached) "detached and unloaded" else "namespace unloaded"))
1647
+ }
1648
+ if (identical(action, "remove")) {
1649
+ if (!installed) return(paste(name, "is not installed"))
1650
+ if (file.access(target_lib, 2L) != 0L) stop("the package library is not writable")
1651
+ was_attached <- name %in% attached
1652
+ was_loaded <- name %in% loadedNamespaces()
1653
+ tryCatch({
1654
+ if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
1655
+ if (name %in% loadedNamespaces()) unloadNamespace(name)
1656
+ utils::remove.packages(name, lib = target_lib)
1657
+ }, error = function(e) {
1658
+ try(if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
1659
+ else if (was_loaded) loadNamespace(name), silent = TRUE)
1660
+ stop(e)
1661
+ })
1662
+ return(paste(name, "removed from", target_lib))
1663
+ }
1664
+
1665
+ repos <- getOption("repos")
1666
+ if (!length(repos) || is.na(repos["CRAN"]) || identical(unname(repos["CRAN"]), "@CRAN@")) {
1667
+ repos <- c(CRAN = "https://cloud.r-project.org")
1668
+ }
1669
+ if (identical(action, "install")) {
1670
+ if (installed && protected) stop("R system packages are protected")
1671
+ writable <- .libPaths()[file.access(.libPaths(), 2L) == 0L]
1672
+ if (!length(writable)) stop("this R session has no writable package library")
1673
+ target_lib <- writable[1L]
1674
+ suppressWarnings(utils::install.packages(name, lib = target_lib, repos = repos,
1675
+ dependencies = NA, quiet = TRUE))
1676
+ if (!requireNamespace(name, quietly = TRUE)) stop("installation did not produce a loadable package")
1677
+ return(paste(name, as.character(utils::packageVersion(name)), "installed"))
1678
+ }
1679
+ if (!installed) stop("the package is not installed")
1680
+ if (file.access(target_lib, 2L) != 0L) stop("the package library is not writable")
1681
+ available <- utils::available.packages(repos = repos)
1682
+ if (!name %in% rownames(available)) stop("the package is not available from the configured repositories")
1683
+ current <- utils::packageVersion(name, lib.loc = target_lib)
1684
+ latest <- numeric_version(available[name, "Version"])
1685
+ if (current >= latest) return(paste(name, as.character(current), "is current"))
1686
+ was_attached <- name %in% attached
1687
+ was_loaded <- name %in% loadedNamespaces()
1688
+ updated <- tryCatch({
1689
+ if (was_attached) detach(paste0("package:", name), character.only = TRUE, unload = FALSE)
1690
+ if (name %in% loadedNamespaces()) unloadNamespace(name)
1691
+ suppressWarnings(utils::install.packages(name, lib = target_lib, repos = repos,
1692
+ dependencies = NA, quiet = TRUE))
1693
+ installed_version <- utils::packageVersion(name, lib.loc = target_lib)
1694
+ if (installed_version < latest) stop("the package update did not complete")
1695
+ if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
1696
+ else if (was_loaded) loadNamespace(name)
1697
+ installed_version
1698
+ }, error = function(e) {
1699
+ try(if (was_attached) suppressPackageStartupMessages(library(name, character.only = TRUE))
1700
+ else if (was_loaded) loadNamespace(name), silent = TRUE)
1701
+ stop(e)
1702
+ })
1703
+ paste(name, as.character(updated), "updated")
1704
+ }
1705
+
1706
+ result <- tryCatch(list(message = perform()), error = function(e) list(error = conditionMessage(e)))
1707
+ emit(c(list(type = "package_action", id = id, action = action, name = name), result))
1708
+ }
1709
+
1710
+ #' Project and renv state, without paths or lockfile contents on the wire.
1711
+ emit_project_status <- function(id) {
1712
+ result <- tryCatch(carmar_project_status(), error = function(e)
1713
+ list(error = conditionMessage(e)))
1714
+ emit(c(list(type = "project_status", id = id), result))
1715
+ }
1716
+
1717
+ #' One explicit, visible environment action: install renv or restore its lock.
1718
+ emit_project_action <- function(id, action) {
1719
+ result <- tryCatch(carmar_project_action(action), error = function(e)
1720
+ list(ok = FALSE, error = conditionMessage(e)))
1721
+ emit(c(list(type = "project_action", id = id, action = action), result))
1722
+ }
1723
+
1724
+ #' Package metadata and documentation index for the Help pane.
1725
+ emit_package_help <- function(id, name) {
1726
+ valid_name <- is.character(name) && length(name) == 1L &&
1727
+ grepl("^[A-Za-z][A-Za-z0-9.]*$", name)
1728
+ if (!valid_name) {
1729
+ emit(list(type = "package_help", id = id, error = "invalid package name"))
1730
+ return(invisible(NULL))
1731
+ }
1732
+ result <- tryCatch({
1733
+ desc <- utils::packageDescription(name)
1734
+ index <- do.call(utils::help, list(package = name))
1735
+ table <- tryCatch(index$info[[2L]], error = function(e) NULL)
1736
+ topics <- if (is.null(table) || !NROW(table)) list() else {
1737
+ columns <- colnames(table)
1738
+ item_col <- intersect(c("Item", "Topic"), columns)[1L]
1739
+ title_col <- intersect(c("Title", "Description"), columns)[1L]
1740
+ if (is.na(item_col)) list() else lapply(seq_len(NROW(table)), function(i) {
1741
+ list(topic = unname(table[i, item_col]),
1742
+ title = if (is.na(title_col)) "" else unname(table[i, title_col]))
1743
+ })
1744
+ }
1745
+ list(title = unname(desc[["Title"]] %||% name),
1746
+ version = unname(desc[["Version"]] %||% ""),
1747
+ description = unname(desc[["Description"]] %||% ""),
1748
+ license = unname(desc[["License"]] %||% ""),
1749
+ topics = topics)
1750
+ }, error = function(e) list(error = conditionMessage(e)))
1751
+ emit(c(list(type = "package_help", id = id, name = name), result))
1752
+ }
1753
+
1754
+ #' R's own help page, rendered to HTML, for the Help pane.
1755
+ emit_help <- function(id, topic) {
1756
+ html <- tryCatch({
1757
+ # `stats::sd` is a qualified NAME, not a help topic — help() finds nothing
1758
+ # for it, so F1 on a namespaced call answered "no help found" for a
1759
+ # function whose page plainly exists. Split it and name the package.
1760
+ args <- if (is.character(topic) && length(topic) == 1L &&
1761
+ grepl("^[A-Za-z.][A-Za-z0-9._]*:::?[A-Za-z.][A-Za-z0-9._]*$", topic)) {
1762
+ parts <- strsplit(topic, ":::?")[[1]]
1763
+ list(parts[2L], package = parts[1L], help_type = "text")
1764
+ } else {
1765
+ list(topic, help_type = "text", try.all.packages = TRUE)
1766
+ }
1767
+ # help() substitutes its argument, so `help(topic)` with `topic` holding
1768
+ # "lm" looks up a topic literally called "topic". do.call passes the value.
1769
+ paths <- do.call(utils::help, args)
1770
+ if (length(paths) == 0L) return(NULL)
1771
+ # .getHelpFile is internal and not exported in every R (it is not in 4.6.1),
1772
+ # so the Rd path is attempted and the rendered TEXT help is the fallback.
1773
+ # A Help pane showing R's own text beats a Help pane showing nothing.
1774
+ rd <- tryCatch(utils:::.getHelpFile(paths[1L]), error = function(e) NULL)
1775
+ if (!is.null(rd)) {
1776
+ paste(utils::capture.output(tools::Rd2HTML(rd)), collapse = "\n")
1777
+ } else {
1778
+ txt <- paste(utils::capture.output(base::print(paths)), collapse = "\n")
1779
+ if (!nzchar(trimws(txt))) NULL
1780
+ else paste0("<pre class=\"carmar-help-text\">",
1781
+ gsub("<", "&lt;", gsub("&", "&amp;", txt), fixed = TRUE), "</pre>")
1782
+ }
1783
+ }, error = function(e) NULL)
1784
+ emit(list(type = "help", id = id, topic = topic, html = html))
1785
+ }
1786
+
1787
+ #' What to show when the pointer rests on a name — RStudio's F1, without F1.
1788
+ #'
1789
+ #' Three things, in the order a reader wants them: what it IS (a function and
1790
+ #' its signature, or a value and its shape), what it is FOR (the help page's
1791
+ #' title), and one paragraph of description. Deliberately not the whole help
1792
+ #' page: a tooltip that fills the screen is a worse Help pane, and the Help
1793
+ #' pane already exists.
1794
+ #'
1795
+ #' Everything is best-effort and every failure is silent — a hover that throws,
1796
+ #' or that blocks on a slow help lookup, is worse than a hover that says
1797
+ #' nothing. `name` may be namespaced (`dplyr::filter`).
1798
+ #'
1799
+ #' @param id Request id.
1800
+ #' @param name Symbol to describe.
1801
+ #' @return Invisibly NULL. Emits one `hover` frame.
1802
+ emit_hover <- function(id, name) {
1803
+ none <- function() {
1804
+ emit(list(type = "hover", id = id, name = name, found = FALSE))
1805
+ invisible(NULL)
1806
+ }
1807
+ if (!is.character(name) || length(name) != 1L || !nzchar(name)) return(none())
1808
+ if (!grepl("^[A-Za-z._][A-Za-z0-9._]*(:::?[A-Za-z._][A-Za-z0-9._]*)?$", name)) return(none())
1809
+
1810
+ parts <- strsplit(name, ":::?")[[1]]
1811
+ pkg <- if (length(parts) == 2L) parts[1L] else NULL
1812
+ sym <- parts[length(parts)]
1813
+
1814
+ # inherits = FALSE, and it matters: get() walks the enclosing environments by
1815
+ # default, so `mean` is "found in globalenv" and every base function gets
1816
+ # labelled .GlobalEnv. Only what the USER bound counts as theirs.
1817
+ obj <- tryCatch({
1818
+ if (!is.null(pkg)) getExportedValue(pkg, sym)
1819
+ else get(sym, envir = globalenv(), inherits = FALSE)
1820
+ }, error = function(e) NULL)
1821
+ # Not a user object: look along the search path (base, attached packages).
1822
+ where <- NULL
1823
+ if (is.null(obj)) {
1824
+ obj <- tryCatch(get(sym), error = function(e) NULL)
1825
+ if (!is.null(obj)) {
1826
+ w <- tryCatch(find(sym)[1L], error = function(e) NA_character_)
1827
+ if (!is.na(w)) where <- sub("^package:", "", w)
1828
+ }
1829
+ } else if (!is.null(pkg)) where <- pkg
1830
+ else where <- ".GlobalEnv"
1831
+
1832
+ if (is.null(obj)) {
1833
+ # A name that is not bound can still have a help page (an S4 generic, a
1834
+ # dataset promise); if it has none either, there is nothing to say.
1835
+ doc <- help_summary(sym, pkg)
1836
+ if (is.null(doc$title) && is.null(doc$description)) return(none())
1837
+ emit(c(list(type = "hover", id = id, name = name, found = TRUE,
1838
+ kind = "topic", package = where), doc))
1839
+ return(invisible(NULL))
1840
+ }
1841
+
1842
+ kind <- if (is.function(obj)) "function" else "value"
1843
+ signature <- NULL
1844
+ detail <- NULL
1845
+ if (is.function(obj)) {
1846
+ signature <- tryCatch({
1847
+ txt <- paste(utils::capture.output(base::print(args(obj))), collapse = " ")
1848
+ txt <- sub("^function\\s*", paste0(sym, " "), txt)
1849
+ txt <- sub("\\s*NULL\\s*$", "", txt)
1850
+ trimws(gsub("\\s+", " ", txt))
1851
+ }, error = function(e) NULL)
1852
+ if (!is.null(signature) && nchar(signature) > 300L) {
1853
+ signature <- paste0(substr(signature, 1L, 297L), "...")
1854
+ }
1855
+ } else {
1856
+ detail <- tryCatch({
1857
+ cls <- paste(class(obj), collapse = "/")
1858
+ dims <- if (!is.null(dim(obj))) paste(dim(obj), collapse = " x ")
1859
+ else if (is.atomic(obj) || is.list(obj)) paste0("length ", length(obj))
1860
+ else NULL
1861
+ paste(c(cls, dims), collapse = " · ")
1862
+ }, error = function(e) NULL)
1863
+ }
1864
+
1865
+ doc <- help_summary(sym, pkg)
1866
+ # Drop the NULLs before they ship. `list(detail = NULL)` survives as a NULL
1867
+ # element and jsonlite renders it as `{}` — which arrived in the tooltip as
1868
+ # the string "[object Object]". An absent field must be absent.
1869
+ frame <- list(type = "hover", id = id, name = name, found = TRUE, kind = kind,
1870
+ package = where, signature = signature, detail = detail)
1871
+ frame <- c(frame, doc)
1872
+ emit(Filter(Negate(is.null), frame))
1873
+ invisible(NULL)
1874
+ }
1875
+
1876
+ #' Title and first description paragraph from a help topic, or NULLs.
1877
+ #'
1878
+ #' Parsed out of R's own TEXT rendering rather than the Rd tree: the internal
1879
+ #' that reads an .Rd file (`utils:::.getHelpFile`) is not exported and is not
1880
+ #' present in every R, so a tooltip built on it would work on one machine and
1881
+ #' not the next. The text layout — a header line, the title, then a
1882
+ #' "Description:" block — has been stable for decades.
1883
+ help_summary <- function(sym, pkg = NULL) {
1884
+ empty <- list(title = NULL, description = NULL)
1885
+ txt <- tryCatch({
1886
+ paths <- if (is.null(pkg)) do.call(utils::help, list(sym, help_type = "text"))
1887
+ else do.call(utils::help, list(sym, package = pkg, help_type = "text"))
1888
+ if (length(paths) == 0L) return(empty)
1889
+ utils::capture.output(tools::Rd2txt(utils:::.getHelpFile(paths[1L]),
1890
+ options = list(underline_titles = FALSE)))
1891
+ }, error = function(e) NULL)
1892
+ if (is.null(txt) || !length(txt)) return(empty)
1893
+
1894
+ lines <- trimws(txt)
1895
+ # The first non-empty line after the "name package:pkg R Documentation"
1896
+ # header is the title.
1897
+ start <- which(grepl("R Documentation", txt, fixed = TRUE))
1898
+ idx <- if (length(start)) start[1L] + 1L else 1L
1899
+ while (idx <= length(lines) && !nzchar(lines[idx])) idx <- idx + 1L
1900
+ title <- if (idx <= length(lines)) lines[idx] else NULL
1901
+
1902
+ desc <- NULL
1903
+ d <- which(grepl("^Description:", lines))
1904
+ if (length(d)) {
1905
+ i <- d[1L] + 1L
1906
+ while (i <= length(lines) && !nzchar(lines[i])) i <- i + 1L
1907
+ para <- character()
1908
+ while (i <= length(lines) && nzchar(lines[i])) { para <- c(para, lines[i]); i <- i + 1L }
1909
+ if (length(para)) desc <- paste(para, collapse = " ")
1910
+ }
1911
+ if (!is.null(desc) && nchar(desc) > 400L) desc <- paste0(substr(desc, 1L, 397L), "...")
1912
+ list(title = title, description = desc)
1913
+ }
1914
+
1915
+ #' Working directory, and moving it — the Files pane's anchor.
1916
+ emit_wd <- function(id, path = NULL) {
1917
+ if (is.character(path) && length(path) == 1L && nzchar(path)) {
1918
+ if (!within_root(path)) {
1919
+ emit(list(type = "stream", id = id, kind = "warning", text = outside_root_msg()))
1920
+ } else {
1921
+ tryCatch(setwd(path.expand(path)), error = function(e)
1922
+ emit(list(type = "stream", id = id, kind = "warning", text = conditionMessage(e))))
1923
+ }
1924
+ }
1925
+ emit(list(type = "wd", id = id, path = getwd()))
1926
+ }
1927
+
1928
+ # The file system lives in fileio.R (shared with the supervisor). Each op is
1929
+ # `emit(fs_x(...))`: fileio.R returns the frame, this process sends it. The
1930
+ # worker still answers them when the supervisor forwards (CARMAR_FILE_OPS=worker,
1931
+ # the transition flag), so nothing here may diverge from fileio.R.
1932
+ emit_files <- function(id, path = NULL, all = FALSE) emit(fs_files(id, path, all, wd = getwd()))
1933
+ emit_mkdir <- function(id, path = NULL) emit(fs_mkdir(id, path, wd = getwd()))
1934
+ emit_renamepath <- function(id, path = NULL, to = NULL) emit(fs_renamepath(id, path, to, wd = getwd()))
1935
+ emit_deletepath <- function(id, paths = NULL, recursive = FALSE) emit(fs_deletepath(id, paths, recursive, wd = getwd()))
1936
+ emit_copypath <- function(id, path = NULL, to = NULL) emit(fs_copypath(id, path, to, wd = getwd()))
1937
+ emit_revealpath <- function(id, path = NULL) emit(fs_revealpath(id, path, wd = getwd()))
1938
+ emit_readfile <- function(id, path = NULL) emit(fs_readfile(id, path, wd = getwd()))
1939
+ emit_writefile <- function(id, path = NULL, text = "", expected = NULL, encoding = NULL, base64 = NULL)
1940
+ emit(fs_writefile(id, path, text, expected, encoding, base64, wd = getwd()))
1941
+ emit_writefiles_atomic <- function(id, files = NULL) emit(fs_writefiles_atomic(id, files, wd = getwd()))
1942
+
1943
+ #' Check whether CarmaR's current R session is usable without collecting user
1944
+ #' material.
1945
+ #'
1946
+ #' This reply is deliberately a FACT ALLOW-LIST. It never contains getwd(),
1947
+ #' R.home(), .libPaths(), environment values, notebook text, or file names.
1948
+ #' The browser turns these booleans and bounded version strings into the support
1949
+ #' report, so adding a field here cannot accidentally export a project path or
1950
+ #' credential.
1951
+ #'
1952
+ #' @param id Request id.
1953
+ #' @return Invisibly NULL. Emits one `doctor` frame.
1954
+ emit_doctor <- function(id) {
1955
+ can_create_in <- function(dir) {
1956
+ if (!is.character(dir) || length(dir) != 1L || !dir.exists(dir)) return(FALSE)
1957
+ probe <- tempfile(".carmar-doctor-", tmpdir = dir)
1958
+ on.exit(unlink(probe, force = TRUE), add = TRUE)
1959
+ isTRUE(tryCatch(file.create(probe, showWarnings = FALSE), error = function(e) FALSE))
1960
+ }
1961
+
1962
+ q_configured <- trimws(Sys.getenv("CARMAR_QUARTO", ""))
1963
+ q_path <- if (nzchar(q_configured)) q_configured else unname(Sys.which("quarto"))
1964
+ q_available <- is.character(q_path) && length(q_path) == 1L && nzchar(q_path) && file.exists(q_path)
1965
+ q_version <- ""
1966
+ if (q_available) {
1967
+ raw_version <- tryCatch(
1968
+ suppressWarnings(system2(q_path, "--version", stdout = TRUE, stderr = TRUE, timeout = 5L)),
1969
+ error = function(e) character())
1970
+ # A version NUMBER only. Never relay arbitrary command output: a wrapper
1971
+ # could print its path, command line, or environment on stderr.
1972
+ hit <- regmatches(paste(raw_version, collapse = " "),
1973
+ regexpr("[0-9]+(?:\\.[0-9]+){1,3}", paste(raw_version, collapse = " "),
1974
+ perl = TRUE))
1975
+ if (length(hit) && nzchar(hit[[1L]])) q_version <- hit[[1L]]
1976
+ }
1977
+
1978
+ required_names <- c("jsonlite", "httpuv", "processx", "knitr", "evaluate")
1979
+ required <- lapply(required_names, function(name) requireNamespace(name, quietly = TRUE))
1980
+ names(required) <- required_names
1981
+ sys <- Sys.info()
1982
+ libs <- .libPaths()
1983
+ rscript_name <- if (identical(unname(sys[["sysname"]]), "Windows")) "Rscript.exe" else "Rscript"
1984
+
1985
+ emit(list(
1986
+ type = "doctor", id = id,
1987
+ runtime = list(
1988
+ r_version = paste(R.version$major, R.version$minor, sep = "."),
1989
+ worker_mode = if (identical(WORKER_MODE, "interactive")) "interactive" else "batch",
1990
+ os = unname(sys[["sysname"]] %||% ""),
1991
+ os_release = unname(sys[["release"]] %||% ""),
1992
+ architecture = unname(sys[["machine"]] %||% ""),
1993
+ locale = Sys.getlocale("LC_CTYPE") %||% "",
1994
+ rscript_available = file.exists(file.path(R.home("bin"), rscript_name))
1995
+ ),
1996
+ permissions = list(
1997
+ working_directory_writable = can_create_in(getwd()),
1998
+ temporary_directory_writable = can_create_in(tempdir()),
1999
+ library_writable = any(vapply(libs, can_create_in, logical(1))),
2000
+ library_count = length(libs)
2001
+ ),
2002
+ quarto = list(
2003
+ available = q_available,
2004
+ configured = nzchar(q_configured),
2005
+ version = q_version
2006
+ ),
2007
+ required_packages = required,
2008
+ deployment = list(
2009
+ loopback = Sys.getenv("CARMAR_BIND", "127.0.0.1") %in% c("", "127.0.0.1", "localhost", "::1"),
2010
+ confinement = nzchar(Sys.getenv("CARMAR_ROOT", "")),
2011
+ audit = nzchar(Sys.getenv("CARMAR_LOG", "")),
2012
+ ai_local_only = identical(Sys.getenv("CARMAR_AI_LOCAL_ONLY", ""), "1"),
2013
+ ai_policy = nzchar(Sys.getenv("CARMAR_AI_POLICY", "")) || nzchar(Sys.getenv("CARMAR_AI_PROVIDERS", "")),
2014
+ origin_required = !identical(Sys.getenv("CARMAR_REQUIRE_ORIGIN", "1"), "0"),
2015
+ trusted_proxy = identical(Sys.getenv("CARMAR_TRUST_PROXY", ""), "1"),
2016
+ unauthenticated = identical(Sys.getenv("CARMAR_ALLOW_UNAUTHENTICATED", ""), "1")
2017
+ ),
2018
+ proxy = list(
2019
+ http = nzchar(Sys.getenv("HTTP_PROXY", "")) || nzchar(Sys.getenv("http_proxy", "")),
2020
+ https = nzchar(Sys.getenv("HTTPS_PROXY", "")) || nzchar(Sys.getenv("https_proxy", "")),
2021
+ bypass = nzchar(Sys.getenv("NO_PROXY", "")) || nzchar(Sys.getenv("no_proxy", ""))
2022
+ )
2023
+ ))
2024
+ invisible(NULL)
2025
+ }
2026
+
2027
+ #' Open a fresh PNG device whose pages land in `dir`.
2028
+ #'
2029
+ #' `%03d` in the filename makes R write one file per page, so a cell that draws
2030
+ #' three figures yields three files with no bookkeeping here.
2031
+ #'
2032
+ #' @param dir Directory to write pages into.
2033
+ #' @param seq Index of the top-level expression, to keep filenames unique.
2034
+ #' @return Invisibly NULL.
2035
+ open_plot_device <- function(dir, seq, dims = NULL) {
2036
+ width <- if (!is.null(dims$width)) dims$width else PLOT_WIDTH
2037
+ height <- if (!is.null(dims$height)) dims$height else PLOT_HEIGHT
2038
+ res <- if (!is.null(dims$res)) dims$res else PLOT_RES
2039
+ if (plot_is_svg(dims)) {
2040
+ # A vector device is sized in INCHES and has no resolution at all — which
2041
+ # is the whole point: its cost does not grow with dpi. The client sends
2042
+ # pixels because that is what a raster device wants, so convert back.
2043
+ file <- file.path(dir, sprintf("e%03d-%%03d.svg", seq))
2044
+ if (identical(SVG_DEVICE, "svglite")) {
2045
+ svglite::svglite(file, width = width / res, height = height / res)
2046
+ } else {
2047
+ grDevices::svg(file, width = width / res, height = height / res)
2048
+ }
2049
+ return(invisible(NULL))
2050
+ }
2051
+ filename <- file.path(dir, sprintf("e%03d-%%03d.png", seq))
2052
+ if (identical(RASTER_DEVICE, "ragg")) {
2053
+ # Headless, and the reason the worker no longer hangs on macOS: no quartz,
2054
+ # so no AppKit event loop for R to block pumping mid-run.
2055
+ ragg::agg_png(filename = filename, width = width, height = height, res = res)
2056
+ } else {
2057
+ grDevices::png(filename = filename, width = width, height = height,
2058
+ res = res, type = PLOT_TYPE)
2059
+ }
2060
+ invisible(NULL)
2061
+ }
2062
+
2063
+ #' The file extension the open device is writing, as a regex.
2064
+ #'
2065
+ #' Harvesting used to hard-code `\\.png$`, so an SVG device produced pages
2066
+ #' that were never collected and the chunk finished with no plots at all.
2067
+ plot_pattern <- function(dims) if (plot_is_svg(dims)) "\\.svg$" else "\\.png$"
2068
+
2069
+ #' Emit one `plot` frame for a finished page file.
2070
+ #'
2071
+ #' Report the size the device was ACTUALLY opened at. Reporting the defaults
2072
+ #' while honouring the request is worse than ignoring the request: the client
2073
+ #' lays out a 900×620 box around a 1400×500 image.
2074
+ #' `res` travels with the image because the client cannot infer it: a
2075
+ #' 1400×900 PNG at res 96 and the same PNG at res 192 are the same pixels
2076
+ #' describing different physical sizes, and the viewer needs the second
2077
+ #' number to display it at its true size instead of upscaling it.
2078
+ #'
2079
+ #' @param id Cell id.
2080
+ #' @param f Path to a finalized PNG page.
2081
+ #' @return Invisibly NULL.
2082
+ emit_plot_frame <- function(id, f, dims = NULL) {
2083
+ # SVG travels base64 too, in the same envelope as PNG. It costs 33% and buys
2084
+ # a zero-line client change: lib/output-pane.js already understands
2085
+ # `image/svg+xml` (isSvg — vector Save SVG, rasterise-on-demand) and already
2086
+ # builds `data:<mime>;base64,<data>`. Sending raw text instead would have
2087
+ # meant an encoding field plus edits to output-pane.js AND knit.js, and an
2088
+ # older client would have rendered nothing at all.
2089
+ svg <- grepl("\\.svg$", f)
2090
+ emit(list(type = "plot", id = id, mime = if (svg) "image/svg+xml" else "image/png",
2091
+ width = if (!is.null(dims$width)) dims$width else PLOT_WIDTH,
2092
+ height = if (!is.null(dims$height)) dims$height else PLOT_HEIGHT,
2093
+ res = if (!is.null(dims$res)) dims$res else PLOT_RES,
2094
+ data = jsonlite::base64_enc(readBin(f, "raw", file.info(f)$size))))
2095
+ invisible(NULL)
2096
+ }
2097
+
2098
+ #' Emit pages the device has finished, leaving the device open.
2099
+ #'
2100
+ #' The `%03d` png device finalizes a page's file only when the NEXT page
2101
+ #' begins (or the device closes), so every file on disk is a complete figure
2102
+ #' and the page still open on the device has no file yet. That page stays
2103
+ #' amendable: `abline()` after `seq_heatmap()` draws onto a live plot instead
2104
+ #' of erroring, and `layout()`/`par()` state survives across statements.
2105
+ #'
2106
+ #' @param id Cell id.
2107
+ #' @param dir Directory the device writes pages into.
2108
+ #' @param seen Character vector of filenames already emitted.
2109
+ #' @return The updated `seen` vector.
2110
+ #' Is this PNG a device's untouched blank page rather than a real plot?
2111
+ #'
2112
+ #' grDevices does not create its first file until something is drawn, so a
2113
+ #' non-zero size meant "a plot happened". ragg writes a fully-formed BLANK page
2114
+ #' the instant the device opens, so size > 0 no longer distinguishes "drew a
2115
+ #' plot" from "opened the device and drew nothing" — which made every cell that
2116
+ #' does not plot emit an empty figure. A blank page for a given width/height/res
2117
+ #' is byte-identical every time, so keep one reference per size and skip any
2118
+ #' harvested file that matches it. Only ragg needs this; other devices return
2119
+ #' FALSE immediately. A real plot never equals the blank, and a plot call that
2120
+ #' drew literally nothing visible is a blank the reader is better off not shown.
2121
+ .blank_png_cache <- new.env(parent = emptyenv())
2122
+ is_blank_plot <- function(f, dims) {
2123
+ if (!identical(RASTER_DEVICE, "ragg")) return(FALSE)
2124
+ w <- if (!is.null(dims$width)) dims$width else PLOT_WIDTH
2125
+ h <- if (!is.null(dims$height)) dims$height else PLOT_HEIGHT
2126
+ r <- if (!is.null(dims$res)) dims$res else PLOT_RES
2127
+ key <- paste(w, h, r, sep = "x")
2128
+ blank <- .blank_png_cache[[key]]
2129
+ if (is.null(blank)) {
2130
+ ref <- tempfile(fileext = ".png")
2131
+ blank <- tryCatch({
2132
+ ragg::agg_png(ref, width = w, height = h, res = r)
2133
+ grDevices::dev.off()
2134
+ if (file.exists(ref)) readBin(ref, "raw", file.info(ref)$size) else raw(0)
2135
+ }, error = function(e) raw(0))
2136
+ if (!is.null(grDevices::dev.list())) try(grDevices::dev.off(), silent = TRUE)
2137
+ unlink(ref)
2138
+ .blank_png_cache[[key]] <- blank
2139
+ }
2140
+ if (!length(blank)) return(FALSE)
2141
+ sz <- file.info(f)$size
2142
+ isTRUE(sz == length(blank)) && identical(readBin(f, "raw", sz), blank)
2143
+ }
2144
+
2145
+ harvest_finished <- function(id, dir, seen, dims = NULL) {
2146
+ files <- list.files(dir, pattern = plot_pattern(dims), full.names = TRUE)
2147
+ # Only files with bytes join `seen`: a file still empty here gets another
2148
+ # look on the next harvest instead of being remembered as already emitted.
2149
+ # A ragg blank page (opened, nothing drawn) has bytes but is not a plot.
2150
+ drawn <- Filter(function(f) file.info(f)$size > 0L && !is_blank_plot(f, dims),
2151
+ sort(setdiff(files, seen)))
2152
+ invisible(lapply(drawn, function(f) emit_plot_frame(id, f, dims)))
2153
+ c(seen, drawn)
2154
+ }
2155
+
2156
+ #' Close any open device and emit one `plot` frame per page produced.
2157
+ #'
2158
+ #' @param id Cell id.
2159
+ #' @param dir Directory the device wrote into.
2160
+ #' @param seen Character vector of filenames already emitted.
2161
+ #' @return The updated `seen` vector.
2162
+ harvest_plots <- function(id, dir, seen, dims = NULL) {
2163
+ if (!is.null(grDevices::dev.list())) {
2164
+ try(grDevices::dev.off(), silent = TRUE)
2165
+ }
2166
+ files <- list.files(dir, pattern = plot_pattern(dims), full.names = TRUE)
2167
+ fresh <- setdiff(files, seen)
2168
+ # A device always creates its first file, even with nothing drawn into it —
2169
+ # empty (0 bytes) for grDevices, a byte-identical blank page for ragg.
2170
+ drawn <- Filter(function(f) file.info(f)$size > 0L && !is_blank_plot(f, dims),
2171
+ sort(fresh))
2172
+ invisible(lapply(drawn, function(f) emit_plot_frame(id, f, dims)))
2173
+ c(seen, fresh)
2174
+ }
2175
+
2176
+ #' Emit a data.frame as structured rows the notebook can put in a real table.
2177
+ #'
2178
+ #' Printing a data.frame gives the caller aligned text; a notebook needs
2179
+ #' columns and types. Capped to a small preview with the true row count
2180
+ #' reported, so a million-row frame never becomes a million-row JSON payload.
2181
+ #'
2182
+ #' @param id Cell id.
2183
+ #' @param df A data.frame.
2184
+ #' @param source The NAME the frame was printed under, when the printed
2185
+ #' expression was a bare symbol, else NULL. It is the page's licence to offer
2186
+ #' "open all N rows" in the data viewer: a symbol is a lookup, so fetching it
2187
+ #' again through `view` runs nothing. An expression is not, so it gets no
2188
+ #' name and no offer -- re-evaluating `head(df) |> transform(...)` to reach
2189
+ #' row 300 would run the user's code a second time, unasked.
2190
+ #' @return Invisibly NULL.
2191
+ emit_dataframe <- function(id, df, source = NULL) {
2192
+ # A returned value is a NOTEBOOK PREVIEW, not the data viewer. Bounding rows
2193
+ # alone is insufficient: one list-column cell may contain a fitted model and
2194
+ # one character cell may contain megabytes. Serialising either recursively
2195
+ # blocks the R session before the browser receives a single result frame.
2196
+ # Keep the preview deliberately small, flatten complex cells to descriptions,
2197
+ # clip text, then enforce a final wire-size ceiling. The full object viewer
2198
+ # remains paginated independently for assigned objects.
2199
+ # 200, not the 10 this sent until 7.70. Ten was fewer than the page's own
2200
+ # ROWS_PER_PAGE of 25, so lib/output-pane.js's row navigator could never
2201
+ # appear at all: the paging it implements had been unreachable since it was
2202
+ # written, and Carmar_docs' guide documented a "Preview 500 of 4000" note the
2203
+ # kernel could not emit. Owner, 2026-09-10: "They are not paginated".
2204
+ #
2205
+ # 200 and not 500, bounded by three ceilings rather than taste. The 256 KB
2206
+ # wire cap below: a 9-column numeric frame is ~200 B/row (200 rows ~ 40 KB),
2207
+ # a 20-column mixed frame ~560 B/row (~112 KB) -- at 500 that ordinary middle
2208
+ # case is 280 KB, so shedding would fire on frames nobody would call large.
2209
+ # And lib/knit.js embeds every preview row verbatim into an exported report,
2210
+ # so this constant multiplies published document weight 20x at 200 and 50x
2211
+ # at 500. The page keeps its own ROW_CAP of 500 as the belt to this brace.
2212
+ preview_rows <- 200L
2213
+ preview_cols <- 20L
2214
+ cell_chars <- 160L
2215
+ preview_bytes <- 256L * 1024L
2216
+ total_rows <- nrow(df)
2217
+ data_cols <- ncol(df)
2218
+
2219
+ # R stores default 1..n row names compactly. Calling rownames(df) expands that
2220
+ # ALTREP to millions of strings, and the old identical(... seq_len(n)) check
2221
+ # built a second vector just to decide there was nothing to show. type=1 asks
2222
+ # R whether names are automatic without materialising them; explicit names
2223
+ # are sliced from the raw attribute only AFTER the 10-row preview is taken.
2224
+ explicit_rownames <- .row_names_info(df, type = 1L) > 0L
2225
+ total_cols <- data_cols + as.integer(explicit_rownames)
2226
+ data_col_cap <- max(0L, preview_cols - as.integer(explicit_rownames))
2227
+ shown_cols <- if (data_cols && data_col_cap) {
2228
+ seq_len(min(data_cols, data_col_cap))
2229
+ } else integer(0)
2230
+ source_types <- if (length(shown_cols)) {
2231
+ unname(vapply(df[shown_cols], function(col) class(col)[1L], character(1)))
2232
+ } else character(0)
2233
+
2234
+ cell_preview <- function(value) {
2235
+ text <- if (is.null(value)) {
2236
+ "NULL"
2237
+ } else if (is.data.frame(value) || is.matrix(value)) {
2238
+ paste(dim(value), collapse = " x ")
2239
+ } else if (is.function(value)) {
2240
+ paste0("function(", formals_string(value), ")")
2241
+ } else if (is.environment(value)) {
2242
+ "<environment>"
2243
+ } else if (is.atomic(value)) {
2244
+ shown <- as.character(utils::head(value, 3L))
2245
+ shown <- substr(shown, 1L, 48L)
2246
+ paste0(paste(shown, collapse = ", "), if (length(value) > 3L) ", ..." else "")
2247
+ } else {
2248
+ paste0("<", class(value)[1L], ">")
2249
+ }
2250
+ substr(text, 1L, cell_chars)
2251
+ }
2252
+
2253
+ safe_column <- function(col, rows) {
2254
+ shown <- utils::head(col, rows)
2255
+ if (is.factor(shown)) return(as.character(shown))
2256
+ if (inherits(shown, "Date") || inherits(shown, "POSIXt")) return(format(shown))
2257
+ if (is.atomic(shown) && is.null(dim(shown)) && !is.complex(shown) && !is.raw(shown)) {
2258
+ if (is.character(shown)) {
2259
+ long <- !is.na(shown) & nchar(shown, type = "chars") > cell_chars
2260
+ shown[long] <- paste0(substr(shown[long], 1L, cell_chars - 3L), "...")
2261
+ }
2262
+ return(shown)
2263
+ }
2264
+ if (is.list(shown)) return(vapply(shown, cell_preview, character(1)))
2265
+ if (!is.null(dim(shown)) && nrow(shown)) {
2266
+ return(vapply(seq_len(nrow(shown)), function(i) cell_preview(shown[i, , drop = TRUE]), character(1)))
2267
+ }
2268
+ rep(paste0("<", class(shown)[1L], ">"), rows)
2269
+ }
2270
+
2271
+ head_df <- utils::head(df[shown_cols], preview_rows)
2272
+ if (ncol(head_df)) head_df[] <- lapply(head_df, safe_column, rows = nrow(head_df))
2273
+ # Informative row names are data (car names in mtcars, coefficient terms in
2274
+ # model summaries), but only their preview travels. Never cbind them onto the
2275
+ # full frame: that copies every column before head() and makes display time
2276
+ # scale with the entire object rather than the preview cells the user will see.
2277
+ if (explicit_rownames) {
2278
+ raw_names <- attr(df, "row.names", exact = TRUE)
2279
+ shown_names <- as.character(utils::head(raw_names, nrow(head_df)))
2280
+ head_df <- cbind(data.frame(rowname = shown_names, stringsAsFactors = FALSE),
2281
+ head_df)
2282
+ source_types <- c("character", source_types)
2283
+ }
2284
+ preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
2285
+ na = "null", digits = NA)
2286
+ # ESTIMATE first, halve only as the fallback. Halving alone (200 -> 100 -> 50)
2287
+ # was invisible from a 10-row head and wasteful from a 200-row one: a
2288
+ # text-heavy frame that would fit 74 rows lands on 50, and the number it
2289
+ # lands on is one the reader cannot predict from anything on screen. This is
2290
+ # view_payload's rule (further up this file), so the two previews shed alike.
2291
+ bytes <- nchar(preview_json, type = "bytes")
2292
+ if (bytes > preview_bytes && nrow(head_df) > 1L) {
2293
+ keep <- max(1L, as.integer(floor(preview_bytes / (bytes / nrow(head_df)))))
2294
+ if (keep < nrow(head_df)) {
2295
+ head_df <- utils::head(head_df, keep)
2296
+ preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
2297
+ na = "null", digits = NA)
2298
+ }
2299
+ }
2300
+ # The estimate assumes rows cost about the same; one enormous cell breaks
2301
+ # that, so the original loop stays underneath it and almost never runs.
2302
+ while (nchar(preview_json, type = "bytes") > preview_bytes && nrow(head_df) > 1L) {
2303
+ head_df <- utils::head(head_df, max(1L, as.integer(floor(nrow(head_df) / 2L))))
2304
+ preview_json <- jsonlite::toJSON(head_df, auto_unbox = TRUE, null = "null",
2305
+ na = "null", digits = NA)
2306
+ }
2307
+ emit(list(
2308
+ type = "dataframe", id = id,
2309
+ # I(): a one-column frame must ship columns/types as arrays, not scalars.
2310
+ columns = I(names(head_df)),
2311
+ types = I(source_types),
2312
+ nrow = total_rows, ncol = total_cols,
2313
+ shown = nrow(head_df), shownCols = ncol(head_df),
2314
+ truncated = total_rows > nrow(head_df) || total_cols > ncol(head_df),
2315
+ # jsonlite drops a NULL, so a frame with no name is byte-identical to what
2316
+ # this emitted before the field existed.
2317
+ source = source,
2318
+ rows = head_df
2319
+ ))
2320
+ invisible(NULL)
2321
+ }
2322
+
2323
+ #' Files named by an htmltools dependency slot, whatever shape it arrived in.
2324
+ #'
2325
+ #' `script`/`stylesheet` can be a character vector, a list of strings, or a
2326
+ #' list with a `src` field — htmltools allows all three and widgets use all
2327
+ #' three.
2328
+ #'
2329
+ #' @param x The dependency slot.
2330
+ #' @return Character vector of relative file paths.
2331
+ dep_files <- function(x) {
2332
+ if (is.null(x)) return(character(0))
2333
+ if (is.list(x)) {
2334
+ if (!is.null(x$src)) return(as.character(x$src))
2335
+ return(unlist(lapply(x, function(e) {
2336
+ if (is.list(e)) as.character(e$src) else as.character(e)
2337
+ }), use.names = FALSE))
2338
+ }
2339
+ as.character(x)
2340
+ }
2341
+
2342
+ #' An htmlwidget as ONE self-contained HTML page — no pandoc.
2343
+ #'
2344
+ #' `htmlwidgets::saveWidget(selfcontained = TRUE)` needs pandoc, which plain R
2345
+ #' installs do not have. But a widget is just rendered tags plus dependencies,
2346
+ #' and every dependency is a file on disk — so read them and inline them.
2347
+ #'
2348
+ #' @param w An htmlwidget.
2349
+ #' @return A single HTML string.
2350
+ widget_standalone_html <- function(w, fit = FALSE) {
2351
+ tags <- if (inherits(w, "htmlwidget")) htmltools::as.tags(w, standalone = FALSE)
2352
+ else htmltools::as.tags(w)
2353
+ rendered <- htmltools::renderTags(tags)
2354
+ deps <- htmltools::resolveDependencies(rendered$dependencies)
2355
+ slurp <- function(path) paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n")
2356
+ inline_one <- function(d) {
2357
+ root <- if (!is.null(d$package)) system.file(d$src$file, package = d$package) else d$src$file
2358
+ if (is.null(root) || !nzchar(root)) return("")
2359
+ css <- vapply(dep_files(d$stylesheet), function(f) {
2360
+ p <- file.path(root, f)
2361
+ if (file.exists(p)) sprintf("<style>%s</style>", slurp(p)) else ""
2362
+ }, character(1))
2363
+ js <- vapply(dep_files(d$script), function(f) {
2364
+ p <- file.path(root, f)
2365
+ # "</script" inside a JS bundle would end the inline tag mid-file; the
2366
+ # standard escape is valid everywhere in JS.
2367
+ if (file.exists(p)) sprintf("<script>%s</script>",
2368
+ gsub("</script", "<\\\\/script", slurp(p), fixed = TRUE)) else ""
2369
+ }, character(1))
2370
+ head_html <- if (!is.null(d$head)) paste(as.character(d$head), collapse = "\n") else ""
2371
+ paste(c(css, js, head_html), collapse = "\n")
2372
+ }
2373
+ # A widget fills its frame (height:100%); a plain HTML fragment is as tall
2374
+ # as its content, and SAYS so: the frame is sandboxed (allow-scripts, no
2375
+ # same-origin), so the page cannot measure it — the document posts its own
2376
+ # scrollHeight to the parent and lib/output-pane.js sizes the iframe.
2377
+ base_css <- if (fit) {
2378
+ paste0("<style>html,body{margin:0;padding:0;overflow:hidden;}",
2379
+ "body{font:13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,",
2380
+ "Helvetica,Arial,sans-serif;color:#1d1d1f;background:transparent;}",
2381
+ "table{border-collapse:collapse;}</style>")
2382
+ } else {
2383
+ "<style>html,body{margin:0;padding:0;height:100%;}</style>"
2384
+ }
2385
+ fit_js <- if (fit) paste0(
2386
+ "<script>(function(){var r=function(){try{parent.postMessage({carmarHtmlHeight:",
2387
+ "Math.ceil(document.documentElement.getBoundingClientRect().height)},'*')}catch(e){}};",
2388
+ "window.addEventListener('load',r);if(window.ResizeObserver){new ResizeObserver(r)",
2389
+ ".observe(document.documentElement)}setTimeout(r,0);setTimeout(r,250)})()</script>") else ""
2390
+ paste0(
2391
+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\">",
2392
+ base_css,
2393
+ paste(vapply(deps, inline_one, character(1)), collapse = "\n"),
2394
+ rendered$head,
2395
+ "</head><body>", rendered$html, fit_js, "</body></html>"
2396
+ )
2397
+ }
2398
+
2399
+ #' Everything that is HTML by nature, as ONE htmltools object — or NULL.
2400
+ #'
2401
+ #' The dispatch that turns a value into a rich frame instead of printed text.
2402
+ #' htmlwidgets were the only rich output until 2026-09-03; a `gt` table, a
2403
+ #' `kable(format = "html")`, an `htmltools::tags` tree, `knitr::asis_output()`
2404
+ #' and `display_html()` all printed their own markup as text. Each is now a
2405
+ #' standalone document in a sandboxed frame — the widget path, generalised —
2406
+ #' so a chunk can produce what a Jupyter display() can: any HTML, its own
2407
+ #' scripts included, with no reach into the notebook.
2408
+ #'
2409
+ #' @param v A value.
2410
+ #' @return `list(x = <htmltools object>, kind = "widget" | "html")`, or NULL
2411
+ #' when `v` is not HTML.
2412
+ rich_html_of <- function(v) {
2413
+ if (inherits(v, "htmlwidget")) return(list(x = v, kind = "widget"))
2414
+ if (inherits(v, "carmar_html")) return(list(x = htmltools::HTML(unclass(v)), kind = "html"))
2415
+ if (inherits(v, c("shiny.tag", "shiny.tag.list", "html"))) return(list(x = v, kind = "html"))
2416
+ if (inherits(v, "knit_asis")) return(list(x = htmltools::HTML(paste(as.character(v), collapse = "\n")), kind = "html"))
2417
+ if (inherits(v, "knitr_kable") && identical(attr(v, "format"), "html")) {
2418
+ return(list(x = htmltools::HTML(paste(as.character(v), collapse = "\n")), kind = "html"))
2419
+ }
2420
+ if (inherits(v, "gt_tbl") && requireNamespace("gt", quietly = TRUE)) {
2421
+ html <- tryCatch(gt::as_raw_html(v, inline_css = TRUE), error = function(e) NULL)
2422
+ if (!is.null(html)) return(list(x = htmltools::HTML(html), kind = "html"))
2423
+ }
2424
+ NULL
2425
+ }
2426
+
2427
+ #' Emit one rich value (see `rich_html_of`) as a `widget` frame.
2428
+ #' @param id Cell id.
2429
+ #' @param rich The list `rich_html_of()` returned.
2430
+ #' @return Invisibly NULL.
2431
+ emit_rich <- function(id, rich) {
2432
+ if (identical(rich$kind, "widget")) return(emit_widget(id, rich$x))
2433
+ if (!requireNamespace("htmltools", quietly = TRUE)) {
2434
+ cat("<html output: the htmltools package is required to display it>\n")
2435
+ return(invisible(NULL))
2436
+ }
2437
+ html <- tryCatch(widget_standalone_html(rich$x, fit = TRUE), error = function(e) NULL)
2438
+ if (is.null(html)) {
2439
+ cat("<html output: could not render it>\n")
2440
+ return(invisible(NULL))
2441
+ }
2442
+ if (nchar(html, type = "bytes") > MAX_WIDGET_BYTES) {
2443
+ cat(sprintf("<html output: %.1f MB is too large to display here>\n",
2444
+ nchar(html, type = "bytes") / 1e6))
2445
+ return(invisible(NULL))
2446
+ }
2447
+ emit(list(type = "widget", id = id, kind = "html", class = class(rich$x)[1L], html = html))
2448
+ invisible(NULL)
2449
+ }
2450
+
2451
+ MAX_WIDGET_BYTES <- 15e6
2452
+
2453
+ #' Emit an htmlwidget (plotly, leaflet, DT, ...) as a `widget` frame.
2454
+ #'
2455
+ #' Falls back to printing a note rather than failing the cell: a widget that
2456
+ #' cannot inline is an inconvenience, not an error in the user's code.
2457
+ #'
2458
+ #' @param id Cell id.
2459
+ #' @param w The htmlwidget.
2460
+ #' @return Invisibly NULL.
2461
+ emit_widget <- function(id, w) {
2462
+ if (!requireNamespace("htmltools", quietly = TRUE)) {
2463
+ cat("<htmlwidget: the htmltools package is required to display it>\n")
2464
+ return(invisible(NULL))
2465
+ }
2466
+ html <- tryCatch(widget_standalone_html(w), error = function(e) NULL)
2467
+ if (is.null(html)) {
2468
+ cat("<htmlwidget: could not render it standalone>\n")
2469
+ return(invisible(NULL))
2470
+ }
2471
+ if (nchar(html, type = "bytes") > MAX_WIDGET_BYTES) {
2472
+ cat(sprintf("<htmlwidget: %.1f MB inlined is too large to display here>\n",
2473
+ nchar(html, type = "bytes") / 1e6))
2474
+ return(invisible(NULL))
2475
+ }
2476
+ emit(list(type = "widget", id = id, kind = "widget", class = class(w)[1L], html = html))
2477
+ invisible(NULL)
2478
+ }
2479
+
2480
+ #' Coerce a matrix to a data.frame so it can be shown as a real table.
2481
+ #'
2482
+ #' Half of what R hands back is a matrix, not a data.frame — `coef(summary(fit))`,
2483
+ #' `cor()`, `table()`. Printing those as aligned text when a data.frame would
2484
+ #' become a sortable, exportable table is an arbitrary distinction to the reader.
2485
+ #' Row names carry meaning in exactly these cases (term names, factor levels), so
2486
+ #' they become a leading column rather than being dropped.
2487
+ #'
2488
+ #' @param m A matrix.
2489
+ #' @return A data.frame.
2490
+ matrix_to_df <- function(m) {
2491
+ # as.data.frame.matrix, NOT the generic. On a `table`-class object — which is
2492
+ # what summary.data.frame() and table() both return — the generic dispatches
2493
+ # to as.data.frame.table and produces a LONG tally (Var1, Var2, Freq),
2494
+ # destroying the layout the reader expects. The .matrix method keeps it wide.
2495
+ df <- as.data.frame.matrix(m, stringsAsFactors = FALSE, optional = TRUE)
2496
+ # optional = TRUE leaves a dimnames-less matrix with NULL column names, and
2497
+ # a dataframe frame without keys shipped "columns":null — the client cannot
2498
+ # read a row object without keys, so synthesize the V1..Vn R itself shows.
2499
+ if (is.null(names(df))) names(df) <- sprintf("V%d", seq_len(ncol(df)))
2500
+ # Promoting row names to a column is emit_dataframe's job and only its job —
2501
+ # doing it in both places produced two `rowname` columns, because cbind()
2502
+ # keeps the row names it just copied.
2503
+ #
2504
+ # What has to happen HERE is deciding whether this matrix's row names mean
2505
+ # anything, because as.data.frame.matrix() destroys the evidence: the blank
2506
+ # names that summary.data.frame() produces come out the other side as "X",
2507
+ # "X.1", "X.2" — synthesised placeholders indistinguishable from real labels.
2508
+ # So the judgement is made against `m` and the answer written onto the frame.
2509
+ rn <- rownames(m)
2510
+ informative <- !is.null(rn) &&
2511
+ !identical(rn, as.character(seq_len(nrow(m)))) &&
2512
+ any(nzchar(trimws(rn)))
2513
+ rownames(df) <- if (informative) rn else NULL
2514
+ df
2515
+ }
2516
+
2517
+ #' Evaluate one cell's source, streaming its output, and report the outcome.
2518
+ #'
2519
+ #' Warnings and messages are forwarded as they occur rather than at the end of
2520
+ #' the cell, so a slow loop that warns halfway through reports halfway through.
2521
+ #' Interrupts are caught, not fatal: the session survives so the next cell
2522
+ #' still sees the variables the interrupted one defined.
2523
+ #'
2524
+ #' @param id Cell identifier echoed back in the done frame.
2525
+ #' @param source R source text.
2526
+ #' One frame's local variables: name, class, and a short shape.
2527
+ #'
2528
+ #' Read-only and defensive. `mget` with `ifnotfound` never forces a promise
2529
+ #' that would error, the value summary is a class-and-dimension line rather
2530
+ #' than a print (printing a frame's locals could take minutes and megabytes),
2531
+ #' and everything is capped. A debugger that hangs the session it is
2532
+ #' debugging has made things worse.
2533
+ frame_vars <- function(env, max_vars = 40L) {
2534
+ if (!is.environment(env)) return(list())
2535
+ names_here <- tryCatch(ls(envir = env, all.names = FALSE), error = function(e) character(0))
2536
+ if (!length(names_here)) return(list())
2537
+ if (length(names_here) > max_vars) names_here <- names_here[seq_len(max_vars)]
2538
+ lapply(names_here, function(nm) {
2539
+ v <- tryCatch(mget(nm, envir = env, ifnotfound = list(NULL))[[1L]],
2540
+ error = function(e) NULL)
2541
+ cls <- tryCatch(paste(class(v), collapse = "/"), error = function(e) "?")
2542
+ shape <- tryCatch({
2543
+ if (is.null(v)) "NULL"
2544
+ else if (is.data.frame(v)) sprintf("%d x %d", nrow(v), ncol(v))
2545
+ else if (is.function(v)) "function"
2546
+ else if (is.atomic(v) && length(v) == 1L) {
2547
+ t <- paste(format(v), collapse = " ")
2548
+ if (nchar(t) > 60L) paste0(substr(t, 1L, 57L), "...") else t
2549
+ } else sprintf("length %d", length(v))
2550
+ }, error = function(e) "?")
2551
+ list(name = nm, class = cls, value = shape)
2552
+ })
2553
+ }
2554
+
2555
+ # ── the debugger (stage 5 slice 2) ──────────────────────────────────────────
2556
+ # Native browser(), not a reimplementation: the worker runs under
2557
+ # `R --interactive`, so the console IS the stdin pipe and a Browse prompt
2558
+ # reads the step commands the supervisor sends as raw lines. Everything here
2559
+ # only decides WHERE to call browser() and reports state in frames; the
2560
+ # stepping engine is R's own.
2561
+
2562
+ # srcname -> integer vector of 1-based breakpoint lines.
2563
+ break_registry <- new.env(parent = emptyenv())
2564
+ # Traces currently armed: list(name, env) per traced function, so they can be
2565
+ # removed before re-arming (a cleared breakpoint must actually stop firing).
2566
+ armed_traces <- list()
2567
+
2568
+ #' Arm function-body breakpoints for everything the registry names.
2569
+ #'
2570
+ #' Re-run after every cell: a redefined function sheds its trace with the old
2571
+ #' object, so arming is idempotent re-derivation from the registry, never an
2572
+ #' increment. findLineNum matches functions whose srcref FILENAME equals the
2573
+ #' registered srcname — which is why run_cell parses chunk source under
2574
+ #' srcfilecopy(srcname, ...).
2575
+ #'
2576
+ #' @return Number of function locations armed.
2577
+ apply_fn_breaks <- function() {
2578
+ for (tr in armed_traces) {
2579
+ tryCatch(suppressMessages(untrace(tr$name, where = tr$env)),
2580
+ error = function(e) NULL)
2581
+ }
2582
+ armed_traces <<- list()
2583
+ if (!identical(WORKER_MODE, "interactive")) return(0L)
2584
+ files <- ls(break_registry, all.names = TRUE)
2585
+ for (file in files) {
2586
+ for (line in break_registry[[file]]) {
2587
+ hits <- tryCatch(
2588
+ utils::findLineNum(sprintf("%s#%d", file, line),
2589
+ envir = globalenv(), lastenv = globalenv()),
2590
+ error = function(e) list())
2591
+ for (h in hits) {
2592
+ ok <- tryCatch({
2593
+ suppressMessages(trace(
2594
+ h$name,
2595
+ tracer = bquote({
2596
+ .carmar_debug_entered(.(file), .(line), "breakpoint")
2597
+ browser()
2598
+ }),
2599
+ at = h$at, where = h$env, print = FALSE))
2600
+ TRUE
2601
+ }, error = function(e) FALSE)
2602
+ if (ok) armed_traces[[length(armed_traces) + 1L]] <<- list(name = h$name, env = h$env)
2603
+ }
2604
+ }
2605
+ }
2606
+ length(armed_traces)
2607
+ }
2608
+
2609
+ #' Set or clear the breakpoints of one source file, then re-arm.
2610
+ #'
2611
+ #' @param id Request id.
2612
+ #' @param file The srcname the client runs its chunks under ("chunk:<id>",
2613
+ #' or a script path).
2614
+ #' @param lines 1-based lines; empty or absent clears the file.
2615
+ #' @return Invisibly NULL. Emits one `debug_breaks` frame.
2616
+ emit_debug_breaks <- function(id, file, lines = NULL) {
2617
+ if (!is.character(file) || length(file) != 1L || !nzchar(file)) {
2618
+ emit(list(type = "debug_breaks", id = id, error = "bad file"))
2619
+ return(invisible(NULL))
2620
+ }
2621
+ if (!identical(WORKER_MODE, "interactive")) {
2622
+ emit(list(type = "debug_breaks", id = id, file = file,
2623
+ error = "the debugger needs an interactive worker (this one is batch)"))
2624
+ return(invisible(NULL))
2625
+ }
2626
+ lines <- suppressWarnings(as.integer(unlist(lines)))
2627
+ lines <- sort(unique(lines[is.finite(lines) & lines >= 1L]))
2628
+ if (length(lines)) assign(file, lines, envir = break_registry)
2629
+ else if (exists(file, envir = break_registry)) rm(list = file, envir = break_registry)
2630
+ armed <- apply_fn_breaks()
2631
+ emit(list(type = "debug_breaks", id = id, file = file,
2632
+ lines = I(as.integer(lines)), functions = armed))
2633
+ }
2634
+
2635
+ #' The live call stack at a debug pause, trimmed like capture_trace.
2636
+ #'
2637
+ #' Bottom harness frames are dropped up to the last eval boundary; top frames
2638
+ #' are dropped through the trace/browser machinery and this file's own debug
2639
+ #' functions, so the first and last things a reader sees are their own calls.
2640
+ #'
2641
+ #' @param calls The sys.calls() of the caller.
2642
+ #' @param frames The sys.frames() of the caller.
2643
+ #' @return A list of list(call, vars, file, line) — innermost LAST.
2644
+ debug_stack <- function(calls, frames, max_frames = 40L) {
2645
+ if (!length(calls)) return(list())
2646
+ texts <- vapply(calls, function(cl) {
2647
+ d <- tryCatch(paste(deparse(cl), collapse = " "), error = function(e) "<call>")
2648
+ if (nchar(d) > 300L) paste0(substr(d, 1L, 297L), "...") else d
2649
+ }, character(1))
2650
+ boundary <- which(startsWith(texts, "withVisible(eval(") |
2651
+ startsWith(texts, "eval(e, globalenv())") |
2652
+ startsWith(texts, "eval(wrapped, globalenv())"))
2653
+ from <- if (length(boundary)) max(boundary) + 1L else 1L
2654
+ to <- length(texts)
2655
+ # Trace/browser plumbing above the user's own frames. Trimming it is not
2656
+ # cosmetic: frame_vars below reads every frame it keeps, and a machinery
2657
+ # frame's environment holds the TRACER EXPRESSION as an unforced promise —
2658
+ # mget() forces what it reads, so describing that frame re-entered the
2659
+ # tracer recursively (a second paused frame, a browser "Called from: mget").
2660
+ ours <- "^(\\.carmar_debug_entered|\\.carmar_debug_where|\\.doTrace|browser\\(|eval\\(expr|eval\\.parent|Reduce\\(|\\{)"
2661
+ while (to >= from && grepl(ours, texts[[to]])) to <- to - 1L
2662
+ if (to < from) return(list())
2663
+ idx <- seq.int(from, to)
2664
+ if (length(idx) > max_frames) idx <- utils::tail(idx, max_frames)
2665
+ lapply(idx, function(k) {
2666
+ ref <- utils::getSrcref(calls[[k]])
2667
+ list(call = texts[[k]],
2668
+ vars = if (k <= length(frames)) frame_vars(frames[[k]]) else list(),
2669
+ file = if (is.null(ref)) NULL else {
2670
+ f <- attr(ref, "srcfile")
2671
+ if (is.null(f) || is.null(f$filename) || !nzchar(f$filename)) NULL else f$filename
2672
+ },
2673
+ line = if (is.null(ref)) NULL else as.integer(ref[[1L]]))
2674
+ })
2675
+ }
2676
+
2677
+ #' The call stack at the moment of an error, as frames a UI can click.
2678
+ #'
2679
+ #' Stage 5 slice 1. Two details make this useful rather than decorative:
2680
+ #'
2681
+ #' IT RUNS BEFORE THE UNWIND. Called from a `withCallingHandlers` error
2682
+ #' handler, so `sys.calls()` still holds the stack. From `tryCatch` it would
2683
+ #' be empty, which is why R users are told to call `traceback()` afterwards.
2684
+ #'
2685
+ #' THE HARNESS IS TRIMMED. The bottom frames are this file's own
2686
+ #' (`run_cell`, `withCallingHandlers`, the `lapply` over expressions) and
2687
+ #' showing them teaches the reader that their error came from CarmaR. Frames
2688
+ #' are dropped up to and including the last `eval(e, globalenv())`, which is
2689
+ #' the boundary between our code and theirs.
2690
+ #'
2691
+ #' @return A list of list(call, file, line) — innermost LAST, the order
2692
+ #' `traceback()` prints and the order people read a stack in.
2693
+ capture_trace <- function(max_frames = 40L) {
2694
+ calls <- sys.calls()
2695
+ if (!length(calls)) return(list())
2696
+ texts <- vapply(calls, function(cl) {
2697
+ d <- tryCatch(paste(deparse(cl), collapse = " "), error = function(e) "<call>")
2698
+ if (nchar(d) > 300L) paste0(substr(d, 1L, 297L), "...") else d
2699
+ }, character(1))
2700
+ # Everything up to the user's own evaluation belongs to the harness.
2701
+ # startsWith, not grepl(fixed = TRUE): with fixed = TRUE the `^` is a
2702
+ # LITERAL caret and the pattern never matches, which silently shipped the
2703
+ # whole boot stack to the browser.
2704
+ boundary <- which(startsWith(texts, "withVisible(eval(") |
2705
+ startsWith(texts, "eval(e, globalenv())"))
2706
+ # No user evaluation on the stack means the error is the harness's own — a
2707
+ # parse error, where R stopped before any expression ran. Those frames are
2708
+ # worker.R's boot and dispatch loop: nothing the user can go to, and
2709
+ # walking them is NOT safe. frame_vars() reads each variable with mget(),
2710
+ # and mget FORCES a promise; the `local()` frame that is this whole file
2711
+ # holds `expr` — the entire worker program — as a promise local() never
2712
+ # forces. Reading it evaluated the program a second time, and that nested
2713
+ # copy sat in its own dispatch loop reading the console: every syntax
2714
+ # error hung the interactive session until Stop (measured 2026-09-07; the
2715
+ # batch worker hid it, since its nested loop drained stdin and exited).
2716
+ # An empty trace is the honest answer, and the only safe one.
2717
+ if (!length(boundary)) return(list())
2718
+ from <- max(boundary) + 1L
2719
+ # And the handler frames at the very top are ours too.
2720
+ to <- length(texts)
2721
+ while (to >= from && grepl("^(capture_trace|\\.handleSimpleError|h\\(simpleError|stop\\()", texts[[to]])) {
2722
+ to <- to - 1L
2723
+ }
2724
+ if (to < from) return(list())
2725
+ idx <- seq.int(from, to)
2726
+ if (length(idx) > max_frames) idx <- utils::tail(idx, max_frames)
2727
+ frames <- sys.frames()
2728
+ lapply(idx, function(k) {
2729
+ ref <- utils::getSrcref(calls[[k]])
2730
+ list(call = texts[[k]],
2731
+ # The frame's own variables, so "what was `n` at the time?" has an
2732
+ # answer without re-running anything. `ls()` and `class()` only — no
2733
+ # user code is evaluated here, and the values are capped hard because
2734
+ # a frame can hold a 2 GB data frame and this rides the same stdout
2735
+ # the cell's output does.
2736
+ vars = if (k <= length(frames)) frame_vars(frames[[k]]) else list(),
2737
+ file = if (is.null(ref)) NULL else {
2738
+ f <- attr(ref, "srcfile")
2739
+ if (is.null(f) || is.null(f$filename) || !nzchar(f$filename)) NULL else f$filename
2740
+ },
2741
+ line = if (is.null(ref)) NULL else as.integer(ref[[1L]]))
2742
+ })
2743
+ }
2744
+
2745
+ #' Return a package name only when R or its message identifies one safely.
2746
+ #'
2747
+ #' `packageNotFoundError` carries a structured `$package` field in current R.
2748
+ #' The narrow message fallback covers older R releases. In both cases the
2749
+ #' allow-list is deliberately the same one used by `package_action`, so an
2750
+ #' error message can never become code or an arbitrary install target.
2751
+ carmar_missing_package <- function(error) {
2752
+ valid <- function(value) {
2753
+ is.character(value) && length(value) == 1L && !is.na(value) &&
2754
+ grepl("^[A-Za-z][A-Za-z0-9.]*$", value)
2755
+ }
2756
+ package <- tryCatch(error$package, error = function(e) NULL)
2757
+ if (valid(package)) return(package)
2758
+
2759
+ message <- tryCatch(conditionMessage(error), error = function(e) "")
2760
+ match <- regexec(
2761
+ "(?:there is no package called|package) [‘’'\"]([A-Za-z][A-Za-z0-9.]*)[‘’'\"](?: is not installed)?",
2762
+ message, perl = TRUE, ignore.case = TRUE)
2763
+ pieces <- regmatches(message, match)[[1L]]
2764
+ if (length(pieces) >= 2L && valid(pieces[[2L]])) pieces[[2L]] else NULL
2765
+ }
2766
+
2767
+ #' @return Invisibly NULL. Emits exactly one `done` frame.
2768
+ run_cell <- function(id, source, dims = NULL, srcname = NULL) {
2769
+ # The whole run — prologue, evaluation, epilogue — under one rule: a
2770
+ # signal-borne interrupt that carries no stop flag for THIS run was sent for
2771
+ # a run that has ended, and is resumed as if it had never come (measured
2772
+ # 2026-09-16: the ladder's late rung reached the next cell's prologue and
2773
+ # reported it interrupted without running it, or its epilogue and reported a
2774
+ # finished cell interrupted). A flagged interrupt is this cell's Stop and
2775
+ # unwinds to the exiting handlers inside as ever. The handler is a CALLING
2776
+ # one established outside the cell's own tryCatch, so the cell's exiting
2777
+ # handlers, being inner, are still the ones that take a real Stop.
2778
+ withCallingHandlers(run_cell_impl(id, source, dims, srcname),
2779
+ interrupt = function(i) if (carmar_interrupt_is_stray(id)) carmar_resume_if_stray(i))
2780
+ }
2781
+ run_cell_impl <- function(id, source, dims = NULL, srcname = NULL) {
2782
+ stopifnot(is.character(source), length(source) == 1L)
2783
+ # First: a signal that arrived between commands must not end THIS cell.
2784
+ carmar_drain_stray_interrupt(id)
2785
+ # The srcname is the chunk's identity in the debugger: functions defined
2786
+ # here carry it in their srcrefs, which is what lets findLineNum resolve
2787
+ # "chunk:<id>#line" to a function and a step. Wire-supplied; anything not a
2788
+ # plain single string means "no debugging identity", never an error.
2789
+ if (!is.character(srcname) || length(srcname) != 1L || is.na(srcname) ||
2790
+ !nzchar(srcname)) srcname <- NULL
2791
+ # `dims` crosses the wire unvalidated (serve.R checks only id and source),
2792
+ # and `$` on an atomic vector THROWS — before the eval's own tryCatch, so a
2793
+ # malformed dims from an older bundle or a hand-built frame used to escape
2794
+ # run_cell entirely and wedge the whole exec route. A dims that is not a
2795
+ # list is no dims.
2796
+ if (!is.null(dims) && !is.list(dims)) dims <- NULL
2797
+ # The print shadow needs to know which run an explicit print(df) belongs to;
2798
+ # cleared on every exit so a print outside a run falls back to base.
2799
+ RUN_STATE$id <- id
2800
+ on.exit(RUN_STATE$id <- NULL, add = TRUE)
2801
+ # THE FRAME MUST NOT GO INTO THE USER'S LOG FILE. emit() is cat() with no
2802
+ # `file=`, so it writes to the CURRENT sink — and `sink("log.txt")` in one
2803
+ # chunk with `sink(NULL)` in the next is ordinary R, as is a package that
2804
+ # leaves a sink open when it errors. Every subsequent done/plot/stream and
2805
+ # every command reply then lands in that file: the supervisor fails a route
2806
+ # only when the worker DIES, so a healthy worker that has stopped answering
2807
+ # produces a cell that spins forever and, behind it, a wedged queue.
2808
+ #
2809
+ # Restoring the depth this run STARTED at, not sink(NULL): a chunk may
2810
+ # legitimately open a sink the user means to keep across cells, and closing
2811
+ # it for them would be a different bug. This only undoes what this cell
2812
+ # left open. (docs/stability-plan.md S4/S1.)
2813
+ sink_depth_at_entry <- sink.number()
2814
+ on.exit({
2815
+ extra <- sink.number() - sink_depth_at_entry
2816
+ if (isTRUE(extra > 0L)) for (i in seq_len(extra)) try(sink(NULL), silent = TRUE)
2817
+ }, add = TRUE, after = FALSE)
2818
+ status <- "ok"
2819
+ detail <- NULL
2820
+ missing_package <- NULL
2821
+ plot_dir <- tempfile("carmar-plots-")
2822
+ dir.create(plot_dir)
2823
+ seen <- character(0)
2824
+ # Asked for vector, cannot give it: SAY SO. Falling back to a raster the
2825
+ # reader did not choose, silently, is how "my plots are blurry" becomes an
2826
+ # unanswerable bug report — and this R may well be one where svg() opens
2827
+ # cleanly and writes nothing (see detect_svg_device).
2828
+ if (isTRUE(dims$format == "svg") && is.null(SVG_DEVICE)) {
2829
+ emit(list(type = "stream", id = id, kind = "warning",
2830
+ text = paste("Vector output is not available in this R —",
2831
+ "install the svglite package (or XQuartz, for Cairo)",
2832
+ "and restart. Drawing at", dims$res %||% PLOT_RES,
2833
+ "dpi instead.\n")))
2834
+ }
2835
+
2836
+ # Where the error happened, captured WHILE it happens. By the time
2837
+ # tryCatch's handler runs the stack has already unwound, so a traceback
2838
+ # taken there is empty — this is the whole reason for the extra calling
2839
+ # handler below, and the reason `at_line` is tracked around each top-level
2840
+ # expression rather than derived afterwards.
2841
+ at_line <- NA_integer_
2842
+ trace_frames <- list()
2843
+
2844
+ # Breakpoint lines registered for this chunk, if any. Top-level hits pause
2845
+ # via a braced browser() below; hits inside function bodies were armed as
2846
+ # traces by apply_fn_breaks and fire on the call.
2847
+ bp_lines <- if (!is.null(srcname) && identical(WORKER_MODE, "interactive") &&
2848
+ exists(srcname, envir = break_registry)) {
2849
+ break_registry[[srcname]]
2850
+ } else integer(0)
2851
+
2852
+ tryCatch(
2853
+ withRestarts(
2854
+ withCallingHandlers(
2855
+ {
2856
+ # keep.source so every top-level expression knows its own line, which
2857
+ # is what "jump from the error to the source" actually needs. The
2858
+ # srcfile names the chunk so the debugger can address its lines.
2859
+ exprs <- if (is.null(srcname)) parse(text = source, keep.source = TRUE)
2860
+ else parse(text = source, keep.source = TRUE,
2861
+ srcfile = srcfilecopy(srcname, source))
2862
+ srcrefs <- attr(exprs, "srcref")
2863
+ # ONE device for the whole cell, not one per statement. Base graphics
2864
+ # are stateful across statements — `layout()` then two plots, `par()`
2865
+ # then a plot, `seq_heatmap()` then `abline()` — and a per-statement
2866
+ # device reset broke every one of those idioms ("plot.new has not
2867
+ # been called yet"). Finished pages still stream out mid-cell via
2868
+ # harvest_finished(); only the page being drawn waits for cell end.
2869
+ dev_seq <- 1L
2870
+ open_plot_device(plot_dir, dev_seq, dims)
2871
+ invisible(lapply(seq_along(exprs), function(.i) {
2872
+ e <- exprs[[.i]]
2873
+ at_line <<- if (!is.null(srcrefs) && length(srcrefs) >= .i &&
2874
+ !is.null(srcrefs[[.i]])) {
2875
+ as.integer(srcrefs[[.i]][[1L]])
2876
+ } else {
2877
+ NA_integer_
2878
+ }
2879
+ # Stop, read at the boundary (carmar_stop_requested): the line after a
2880
+ # busy call that no signal could interrupt does not run.
2881
+ carmar_stop_if_requested(id)
2882
+ # Where R is, for the page's progress bar (RStudio's green line
2883
+ # down the chunk): one frame per top-level expression, BEFORE it
2884
+ # runs, naming the lines it spans — so a 40-second model fit shows
2885
+ # which lines it is on instead of a box-wide pulse. Not output:
2886
+ # the client paints it live and never keeps it.
2887
+ emit(list(type = "progress", id = id, index = .i, total = length(exprs),
2888
+ line = if (is.na(at_line)) NULL else at_line,
2889
+ end = if (is.na(at_line)) NULL
2890
+ else as.integer(srcrefs[[.i]][[3L]])))
2891
+ # A top-level breakpoint pauses BEFORE its expression, inside a
2892
+ # constructed block, so R's own stepper takes over from there: `n`
2893
+ # runs the expression whole, `s` steps into its calls, `c` finishes
2894
+ # the block. The block's value and visibility are the expression's
2895
+ # own, so autoprint below is unchanged.
2896
+ res <- if (!is.na(at_line) && at_line %in% bp_lines) {
2897
+ wrapped <- as.call(list(
2898
+ as.name("{"),
2899
+ bquote(.carmar_debug_entered(.(srcname), .(at_line), "breakpoint")),
2900
+ quote(browser()),
2901
+ e))
2902
+ withVisible(eval(wrapped, globalenv()))
2903
+ } else {
2904
+ withVisible(eval(e, globalenv()))
2905
+ }
2906
+ if (isTRUE(res$visible)) {
2907
+ v <- res$value
2908
+ rich <- rich_html_of(v)
2909
+ if (!is.null(rich)) emit_rich(id, rich)
2910
+ # `e` is the expression that produced this. A bare symbol names an
2911
+ # object the viewer can fetch again for free; anything else does
2912
+ # not, and must not be offered as though it could.
2913
+ else if (is.data.frame(v)) emit_dataframe(id, v,
2914
+ source = if (is.symbol(e)) as.character(e) else NULL)
2915
+ else if (is.matrix(v) && nrow(v) > 0L && ncol(v) > 0L) emit_dataframe(id, matrix_to_df(v),
2916
+ source = if (is.symbol(e)) as.character(e) else NULL)
2917
+ else base::print(v)
2918
+ }
2919
+ # Re-arm function breakpoints after EVERY top-level expression, not
2920
+ # after the cell: the RStudio-typical chunk defines a function and
2921
+ # calls it three lines later, and a trace armed at cell end would
2922
+ # miss that first call entirely. No-op while no breakpoint is set.
2923
+ if (length(ls(break_registry, all.names = TRUE))) {
2924
+ tryCatch(apply_fn_breaks(), error = function(err) NULL)
2925
+ }
2926
+ seen <<- harvest_finished(id, plot_dir, seen, dims)
2927
+ # User code may close our device (an explicit dev.off() in the
2928
+ # cell). Reopen under a FRESH sequence number: reusing e001-*.png
2929
+ # would overwrite files already emitted, and `seen` would silently
2930
+ # swallow the replacements.
2931
+ if (is.null(grDevices::dev.list())) {
2932
+ dev_seq <<- dev_seq + 1L
2933
+ open_plot_device(plot_dir, dev_seq, dims)
2934
+ }
2935
+ }))
2936
+ # …and after the last expression: a Stop that arrived while it ran ends the cell interrupted.
2937
+ carmar_stop_if_requested(id)
2938
+ },
2939
+ # The stray-signal rule (see run_cell), established INSIDE the cell's own
2940
+ # tryCatch so it is asked before the exiting handler below can unwind.
2941
+ interrupt = function(i) if (carmar_interrupt_is_stray(id)) carmar_resume_if_stray(i),
2942
+ warning = function(w) {
2943
+ emit(list(type = "stream", id = id, kind = "warning",
2944
+ text = conditionMessage(w)))
2945
+ invokeRestart("muffleWarning")
2946
+ },
2947
+ message = function(m) {
2948
+ emit(list(type = "stream", id = id, kind = "message",
2949
+ text = sub("\n$", "", conditionMessage(m))))
2950
+ invokeRestart("muffleMessage")
2951
+ },
2952
+ # The traceback, taken BEFORE the stack unwinds. A calling handler runs
2953
+ # inside the erroring frame; tryCatch's handler runs after the unwind,
2954
+ # where sys.calls() is empty. This one only RECORDS — it does not handle
2955
+ # the error, so the tryCatch below still decides the cell's fate.
2956
+ error = function(e) {
2957
+ trace_frames <<- capture_trace()
2958
+ }
2959
+ ),
2960
+ # The debugger's Stop. `Q` at a Browse prompt would abort past the
2961
+ # dispatch loop and zombie the worker; invoking this restart instead
2962
+ # unwinds the browser and the rest of the cell, lands here, and the cell
2963
+ # reports itself stopped like any interrupt.
2964
+ carmar_abort_cell = function() {
2965
+ status <<- "interrupted"
2966
+ detail <<- "Stopped from the debugger"
2967
+ }
2968
+ ),
2969
+ error = function(e) {
2970
+ status <<- "error"
2971
+ detail <<- conditionMessage(e)
2972
+ missing_package <<- carmar_missing_package(e)
2973
+ # A parse error has no stack and no expression index; its position comes
2974
+ # from the message instead, exactly as the analyzer reads it.
2975
+ emit(list(type = "traceback", id = id,
2976
+ message = conditionMessage(e),
2977
+ # 1-based line WITHIN this chunk's source. NA when the failure
2978
+ # happened outside any top-level expression (a parse error).
2979
+ line = if (is.na(at_line)) NULL else at_line,
2980
+ call = tryCatch(paste(deparse(conditionCall(e)), collapse = " "),
2981
+ error = function(x) NULL),
2982
+ frames = trace_frames))
2983
+ },
2984
+ interrupt = function(i) {
2985
+ status <<- "interrupted"
2986
+ detail <<- "Execution interrupted"
2987
+ }
2988
+ )
2989
+
2990
+ # An interrupted or failed cell may still have drawn something, and it always
2991
+ # leaves a device open — close it before the next cell inherits it.
2992
+ seen <- tryCatch(harvest_plots(id, plot_dir, seen, dims),
2993
+ error = function(e) seen, interrupt = function(i) seen)
2994
+ unlink(plot_dir, recursive = TRUE)
2995
+
2996
+ flush(stdout())
2997
+ # `cwd`: the supervisor answers file ops itself and resolves a relative path
2998
+ # against the directory the WORKER is in — setwd() in this very run may have
2999
+ # moved it, so every done frame says where R now stands.
3000
+ emit(list(type = "done", id = id, status = status, message = detail,
3001
+ missingPackage = missing_package, cwd = getwd()))
3002
+ }
3003
+
3004
+ #' Has the supervisor asked to stop run `id`? (host/engine-stdio.mjs `requestStop`)
3005
+ #'
3006
+ #' Stop is a SIGINT, and a SIGINT cannot always reach R: libc's system() ignores
3007
+ #' it in R while the child runs, so after the group signal ends the child R walks
3008
+ #' on to the next line unless a second signal happens to land in the instant
3009
+ #' between system() returning and that line starting — on Linux it never did
3010
+ #' (`beatrina check`, 2026-09-16). So the supervisor also writes a flag file,
3011
+ #' `stop-<id>` in CARMAR_STOP_DIR, and the cell reads it at every top-level
3012
+ #' expression boundary. The flag is removed when read; no directory, no flag.
3013
+ #'
3014
+ #' @param id The wire id of the running cell.
3015
+ #' @return TRUE when a stop was requested (and the flag consumed), else FALSE.
3016
+ carmar_stop_requested <- function(id) {
3017
+ dir <- Sys.getenv("CARMAR_STOP_DIR", "")
3018
+ if (!nzchar(dir) || !is.character(id) || length(id) != 1L || is.na(id)) return(FALSE)
3019
+ flag <- file.path(dir, paste0("stop-", id))
3020
+ if (!file.exists(flag)) return(FALSE)
3021
+ unlink(flag)
3022
+ TRUE
3023
+ }
3024
+
3025
+ #' Raise R's own interrupt condition when a stop was requested, so the cell ends
3026
+ #' through the same `interrupt` handler a signal reaches.
3027
+ #' @param id The wire id of the running cell.
3028
+ #' @return NULL, invisibly, when no stop was requested.
3029
+ carmar_stop_if_requested <- function(id) {
3030
+ if (carmar_stop_requested(id)) {
3031
+ signalCondition(structure(class = c("interrupt", "condition"),
3032
+ list(message = "Execution interrupted", call = NULL)))
3033
+ }
3034
+ invisible(NULL)
3035
+ }
3036
+
3037
+ #' Is a stop flag for run `id` on disk? Read without consuming it.
3038
+ #' @param id The wire id of the running cell.
3039
+ #' @return TRUE when the supervisor has asked this run to stop.
3040
+ carmar_stop_pending <- function(id) {
3041
+ dir <- Sys.getenv("CARMAR_STOP_DIR", "")
3042
+ if (!nzchar(dir) || !is.character(id) || length(id) != 1L || is.na(id)) return(FALSE)
3043
+ file.exists(file.path(dir, paste0("stop-", id)))
3044
+ }
3045
+
3046
+ #' Is an interrupt raised inside run `id` NOT this run's Stop?
3047
+ #'
3048
+ #' Under a supervisor that writes stop flags (CARMAR_STOP_DIR set), every real
3049
+ #' Stop writes `stop-<id>` BEFORE it sends a signal, so a signal-borne interrupt
3050
+ #' with no flag for this run was sent for another — one that has already ended.
3051
+ #' Without a flag folder (an older supervisor) nothing can be told apart, and
3052
+ #' every interrupt is this run's, as it always was.
3053
+ #' @param id The wire id of the running cell.
3054
+ #' @return TRUE only when a flag folder exists and holds no flag for `id`.
3055
+ carmar_interrupt_is_stray <- function(id) {
3056
+ nzchar(Sys.getenv("CARMAR_STOP_DIR", "")) && !carmar_stop_pending(id)
3057
+ }
3058
+
3059
+ #' Resume from an interrupt that belongs to a run that has already ended.
3060
+ #'
3061
+ #' A Stop is a SIGINT, and a SIGINT sent for one run can reach R after that run
3062
+ #' has finished — measured 2026-09-16 (tools/beatrina rigs, LEARNINGS.md): the
3063
+ #' supervisor's second ladder rung, spawned 60 ms after the first, landed 3 ms
3064
+ #' after the NEXT command had been written. What R does with such a signal
3065
+ #' depends only on where it is raised, and every place is wrong in its own way:
3066
+ #' inside `readLines` after the line is in hand it threw the command away
3067
+ #' (the session then sits silent behind a command that never ran — the 8-hour
3068
+ #' soak's lost session); between parsing and dispatch it escaped every handler,
3069
+ #' reached the `options(error=)` guard and ended R (the session's variables with
3070
+ #' it); in a cell's prologue it reported the cell interrupted without running it.
3071
+ #'
3072
+ #' R raises a signal-borne interrupt from `R_CheckUserInterrupt` with a `resume`
3073
+ #' restart (`?conditions`: "the interrupt condition can be resumed"), so a
3074
+ #' CALLING handler that invokes it continues the interrupted computation as if
3075
+ #' the signal had never come. That is exactly right for a signal that is not for
3076
+ #' the code it interrupted, and every window between two commands is such a
3077
+ #' place: a Stop for a run that is still running lands inside that run, never
3078
+ #' here. The console reader's own interrupt (raised while it waits, with no
3079
+ #' `resume`) falls through to the exiting handler as before — nothing was
3080
+ #' consumed, so nothing is lost by going round again.
3081
+ #'
3082
+ #' @param i The interrupt condition.
3083
+ #' @return Does not return when a `resume` restart exists; NULL otherwise.
3084
+ carmar_resume_if_stray <- function(i) {
3085
+ if (!is.null(findRestart("resume"))) invokeRestart("resume")
3086
+ invisible(NULL)
3087
+ }
3088
+
3089
+ #' Raise and discard any interrupt still pending from a signal that arrived
3090
+ #' between commands, so it cannot end the cell about to run. A signal that is
3091
+ #' pending when a cell starts was sent before the cell existed; the one signal
3092
+ #' that IS for this cell — a Stop pressed after it started — travels with a flag
3093
+ #' the supervisor writes first, so a flagged interrupt is honoured, not dropped.
3094
+ #'
3095
+ #' R checks for a pending interrupt every ~1000 evaluations (eval.c) and at the
3096
+ #' back edge of a compiled loop every 1023; a loop of 1200 trivial iterations is
3097
+ #' therefore certain to raise one if it is there, and costs tens of microseconds
3098
+ #' when it is not. Caught with an EXITING handler, not resumed: a pending
3099
+ #' interrupt raised from the event check is offered without a `resume` restart
3100
+ #' (measured 2026-09-16 — the resuming version of this function reported the
3101
+ #' cell interrupted at the zero-millisecond offset, 7 times in 8), and the loop
3102
+ #' has nothing worth resuming anyway.
3103
+ #' @param id The wire id of the cell about to run.
3104
+ #' @return NULL, invisibly.
3105
+ carmar_drain_stray_interrupt <- function(id) {
3106
+ raised <- tryCatch({
3107
+ n <- 0L
3108
+ for (k in seq_len(1200L)) n <- n + 1L
3109
+ FALSE
3110
+ }, interrupt = function(i) TRUE)
3111
+ if (isTRUE(raised) && carmar_stop_pending(id)) {
3112
+ signalCondition(structure(class = c("interrupt", "condition"),
3113
+ list(message = "Execution interrupted", call = NULL)))
3114
+ }
3115
+ invisible(NULL)
3116
+ }
3117
+
3118
+ #' Turn one line into a command, or NULL for a line that is not one.
3119
+ #'
3120
+ #' Wrapped in the resume handler because this is the window a late Stop signal
3121
+ #' used to escape from: the line is consumed, no cell is running yet, and an
3122
+ #' interrupt raised in `fromJSON` had no handler above it. A command must be an
3123
+ #' object with a string `type`. Anything else — a bare number, an array,
3124
+ #' `{"type":[1,2]}` — used to reach `cmd$type` on an atomic vector and take the
3125
+ #' whole worker down with it, ending the session.
3126
+ #' @param line One line as read_command returned it.
3127
+ #' @return The command list, or NULL.
3128
+ carmar_parse_command <- function(line) {
3129
+ withCallingHandlers({
3130
+ cmd <- tryCatch(jsonlite::fromJSON(line), error = function(e) NULL)
3131
+ if (!is.list(cmd) || !is.character(cmd$type) || length(cmd$type) != 1L) return(NULL)
3132
+ resolve_cmdfile(cmd)
3133
+ }, interrupt = carmar_resume_if_stray)
3134
+ }
3135
+
3136
+ #' Read one command line from stdin, tolerating an interrupt that lands while
3137
+ #' the worker is idle (a Stop pressed with nothing running must not kill R).
3138
+ #'
3139
+ #' In interactive mode this reads through R's CONSOLE (stdin()), not a file
3140
+ #' connection on fd 0 — deliberately. The console is the reader a native
3141
+ #' browser() prompt uses, so dispatch reads and debugger reads share one
3142
+ #' buffer and cannot steal bytes from each other. A SIGINT landing in a
3143
+ #' console read raises an interrupt condition exactly as it does on the file
3144
+ #' connection (measured), so the same handler serves both.
3145
+ #'
3146
+ #' @param con Open text connection on stdin, or stdin() in interactive mode.
3147
+ #' @return A single line, character(0) at EOF, or NA_character_ if interrupted.
3148
+ read_command <- function(con) {
3149
+ # Two interrupts can land here and they must be told apart. The console
3150
+ # reader's own, raised while it waits (no `resume` restart): nothing has been
3151
+ # consumed, and NA sends the loop round again. And a signal that arrived just
3152
+ # as the line was being read, raised only at R's next interrupt check — AFTER
3153
+ # readLines has the line, with `resume` on offer: unwinding there threw the
3154
+ # command away, and the supervisor then waited forever for a reply that could
3155
+ # not come (the 8-hour soak of 2026-09-16). Resuming lets readLines return the
3156
+ # line it has. What no handler here can save is a line the reader itself was
3157
+ # half-way through when its interrupt fired — the supervisor's `ack` deadline
3158
+ # (host/worker-plane.mjs) is the answer to that one.
3159
+ line <- tryCatch(
3160
+ withCallingHandlers(readLines(con, n = 1L, warn = FALSE), interrupt = carmar_resume_if_stray),
3161
+ interrupt = function(i) NA_character_
3162
+ )
3163
+ # Strip the supervisor's comment tag; a line without it (older supervisor,
3164
+ # test harness writing bare NDJSON) passes through untouched.
3165
+ if (length(line) == 1L && !is.na(line) && nzchar(CMD_PREFIX) &&
3166
+ startsWith(line, CMD_PREFIX)) {
3167
+ line <- substring(line, nchar(CMD_PREFIX) + 1L)
3168
+ }
3169
+ line
3170
+ }
3171
+
3172
+ #' Resolve a `cmdfile` stub to the command it spilled.
3173
+ #'
3174
+ #' The interactive console reader wedges on lines past ~40 KB, so the
3175
+ #' supervisor writes oversized commands to a 0600 temp file and sends a stub
3176
+ #' naming it. The file is consumed exactly once and deleted before the
3177
+ #' command runs, success or failure.
3178
+ #'
3179
+ #' @param cmd A parsed command list.
3180
+ #' @return The command to dispatch — `cmd` itself, or the spilled one.
3181
+ resolve_cmdfile <- function(cmd) {
3182
+ if (!identical(cmd$type, "cmdfile")) return(cmd)
3183
+ path <- cmd$path
3184
+ if (!is.character(path) || length(path) != 1L || !file.exists(path)) return(NULL)
3185
+ text <- tryCatch(paste(readLines(path, warn = FALSE, encoding = "UTF-8"),
3186
+ collapse = "\n"),
3187
+ error = function(e) NULL)
3188
+ unlink(path)
3189
+ if (is.null(text)) return(NULL)
3190
+ inner <- tryCatch(jsonlite::fromJSON(text), error = function(e) NULL)
3191
+ if (!is.list(inner) || !is.character(inner$type) || length(inner$type) != 1L) return(NULL)
3192
+ inner
3193
+ }
3194
+
3195
+ `%||%` <- function(a, b) if (is.null(a)) b else a
3196
+
3197
+ # `View(df)` must do what it does in RStudio. Overriding it on the SEARCH PATH
3198
+ # rather than in globalenv keeps the user's environment clean (the Environment
3199
+ # pane shows their objects, not ours) while still shadowing utils::View.
3200
+ carmar_tools <- new.env()
3201
+ # Debug hooks live on the SEARCH PATH, not in this file's private scope, for a
3202
+ # reason of visibility: a trace() tracer evaluates in the traced function's
3203
+ # frame, whose lexical chain ends at globalenv() and then the search path —
3204
+ # the worker's private local() is not on it. Same for expressions typed at a
3205
+ # Browse prompt, which is how .carmar_debug_where is called.
3206
+ carmar_tools$.carmar_debug_entered <- function(file, line, reason = "breakpoint") {
3207
+ # Snapshot FIRST, then drop this function's own frame. Taking sys.calls()
3208
+ # lazily inside the emit arguments would extend the stack through
3209
+ # debug_stack/frame_vars themselves, defeat the machinery trimming, and put
3210
+ # tracer-holding frames back in reach of frame_vars — the recursion bug.
3211
+ calls <- sys.calls()
3212
+ frames <- sys.frames()
3213
+ n <- length(calls)
3214
+ emit(list(type = "debug", event = "paused",
3215
+ file = file, line = as.integer(line), reason = reason,
3216
+ stack = debug_stack(calls[-n], frames[-n]),
3217
+ locals = frame_vars(parent.frame())))
3218
+ invisible(NULL)
3219
+ }
3220
+ carmar_tools$.carmar_debug_where <- function() {
3221
+ calls <- sys.calls()
3222
+ frames <- sys.frames()
3223
+ n <- length(calls)
3224
+ emit(list(type = "debug", event = "where",
3225
+ stack = debug_stack(calls[-n], frames[-n]),
3226
+ locals = frame_vars(parent.frame())))
3227
+ invisible(NULL)
3228
+ }
3229
+ #' Show HTML in the chunk's result — a string of markup, an htmltools tree,
3230
+ #' a gt table, a kable, an htmlwidget. The one verb for "this is HTML":
3231
+ #' Jupyter's display(HTML(...)). Outside a running chunk it prints the markup.
3232
+ #' @param x HTML as a character vector (joined with newlines) or any value
3233
+ #' `rich_html_of()` understands.
3234
+ #' @return Invisibly `x`.
3235
+ carmar_tools$display_html <- function(x) {
3236
+ rich <- if (is.character(x)) {
3237
+ list(x = htmltools::HTML(paste(x, collapse = "\n")), kind = "html")
3238
+ } else {
3239
+ rich_html_of(x)
3240
+ }
3241
+ if (is.null(rich)) {
3242
+ stop("display_html(): not HTML — pass a character string of markup, an htmltools tag, ",
3243
+ "a gt table, a kable(format = \"html\") or an htmlwidget (got ", class(x)[1L], ")",
3244
+ call. = FALSE)
3245
+ }
3246
+ if (is.null(RUN_STATE$id)) { cat(as.character(rich$x), "\n"); return(invisible(x)) }
3247
+ emit_rich(RUN_STATE$id, rich)
3248
+ invisible(x)
3249
+ }
3250
+ carmar_tools$View <- function(x, title = NULL) {
3251
+ # The LABEL is what the user typed; the FETCH NAME is where the viewer reads
3252
+ # it from. Conflating them titled `View(mtcars)` as `.carmar_view`, because
3253
+ # mtcars lives in the datasets package rather than the global environment.
3254
+ label <- if (!is.null(title)) title else deparse(substitute(x))
3255
+ assign(".carmar_view", x, envir = globalenv())
3256
+ emit_view("view-request", ".carmar_view", label = label)
3257
+ invisible(NULL)
3258
+ }
3259
+ attach(carmar_tools, name = "carmar:tools", warn.conflicts = FALSE)
3260
+
3261
+ # Interactive mode reads the console; batch mode opens fd 0 as a connection.
3262
+ # The console read is what lets browser() prompts and the dispatch loop share
3263
+ # one input stream — see read_command.
3264
+ con <- if (identical(WORKER_MODE, "interactive")) stdin()
3265
+ else file("stdin", open = "rt", blocking = TRUE)
3266
+
3267
+ # Die-fast guard for the interactive worker. A batch worker that suffers an
3268
+ # uncaught escape simply ends its script and exits, and the supervisor treats
3269
+ # the dead process as fatal. An interactive worker would instead abort to R's
3270
+ # raw top-level prompt — alive, but with the dispatch loop gone: a zombie the
3271
+ # supervisor cannot tell from a healthy idle worker. This restores parity.
3272
+ # Errors raised AT a Browse prompt also fire this handler, but quit() is a
3273
+ # no-op inside a browser context (measured), so a debug-console typo cannot
3274
+ # kill the session.
3275
+ if (identical(WORKER_MODE, "interactive")) {
3276
+ options(error = function() quit(save = "no", status = 70L))
3277
+ }
3278
+
3279
+ # macOS 26 registers command-line R as a foreground application after Aqua or
3280
+ # AppKit initializes, even when the process was launched by CarmaR's UIElement
3281
+ # helper. The supervisor loads the packaged marker before sourcing this file
3282
+ # (preventing an initial Dock flash); repeat the transition here, after all
3283
+ # worker initialization and immediately before `ready`, so any framework that
3284
+ # promoted R during startup cannot leave this document kernel in the Dock.
3285
+ # Existing workers cannot be changed by a newly installed build; every fresh
3286
+ # or restarted worker reaches this point automatically.
3287
+ # OPT-IN — see macos_background_boot in kernel.R. The background transform hides
3288
+ # the Dock icon but wedges R's console read on a GUI-launched macOS 26 worker,
3289
+ # so it is off unless CARMAR_MARK_BACKGROUND=1 is set.
3290
+ if (identical(unname(Sys.info()[["sysname"]]), "Darwin") &&
3291
+ identical(Sys.getenv("CARMAR_MARK_BACKGROUND", "0"), "1") &&
3292
+ is.loaded("carmar_mark_background")) {
3293
+ invisible(try(.C("carmar_mark_background", result = integer(1)), silent = TRUE))
3294
+ }
3295
+
3296
+ # The ready frame says WHICH R this is. A session that silently uses the wrong
3297
+ # installation looks identical to one using the right one until `library(tna)`
3298
+ # fails — so the home and the library count are reported up front.
3299
+ # The command vocabulary, announced.
3300
+ #
3301
+ # An unknown command is SILENTLY IGNORED by the dispatch loop (no else, no
3302
+ # error), which is the right call for a protocol that must not die on a
3303
+ # stray frame — but it means a client asking an older kernel for a command
3304
+ # it has never heard of waits out its own timeout and then guesses why. The
3305
+ # import wizard's `choose` allowed 320 s for a human at a file dialog, so an
3306
+ # old kernel turned "Import Data…" into five minutes of nothing.
3307
+ #
3308
+ # Advertising the vocabulary lets a client know instantly. Kernels older than
3309
+ # this simply omit the field, and clients fall back to probing.
3310
+ #
3311
+ # A fresh worker is a fresh R, always. Until 7.59 a "Restart into" handoff
3312
+ # asked this worker to `save.image()` and the successor's first worker loaded
3313
+ # it back (`workspace_save` / CARMAR_RESTORE_WORKSPACE / a `restored` field
3314
+ # here). That is gone by decision — the owner's "restore work has been
3315
+ # abysmally bad and almost invariably a failure" — and the rule is: a session
3316
+ # is a process you can see; open → R starts, close → R stops, restart → a
3317
+ # fresh R. The saved document is the record. Do not add a restore path back.
3318
+ emit(list(type = "ready", pid = Sys.getpid(), r = R.version.string, cwd = getwd(),
3319
+ # I(): a single library path must still ship as an array.
3320
+ home = R.home(), libs = I(.libPaths()),
3321
+ # "interactive" means a native browser() can pause this worker — the
3322
+ # debugger exists. "batch" (Windows, or no R binary beside Rscript)
3323
+ # means it cannot, and clients must not offer breakpoints.
3324
+ mode = WORKER_MODE,
3325
+ features = I(c("document_exec_v1", "command_ack_v1")),
3326
+ commands = I(c("exec", "env", "obj", "struct", "parse", "format", "doctor",
3327
+ "complete", "packages", "package_action", "package_help",
3328
+ "project_status", "project_action",
3329
+ "help", "hover", "wd", "files", "sniff",
3330
+ "import", "readfile", "writefile", "writefiles_atomic", "view", "colstats",
3331
+ "mkdir", "renamepath", "deletepath", "copypath", "revealpath",
3332
+ "rm",
3333
+ if (identical(WORKER_MODE, "interactive")) "debug_breaks"))))
3334
+ # (No package count here on purpose: installed.packages() reads every
3335
+ # package's DESCRIPTION — 0.3–1.8 s on a big library — and no client ever
3336
+ # consumed the number. The packages PANE asks the `packages` op on demand.)
3337
+
3338
+ # ── the command loop ─────────────────────────────────────────────────────────
3339
+ #
3340
+ # One iteration is one command: read, parse, acknowledge, dispatch. Every part
3341
+ # of it has an interrupt handler with a meaning — the reader's (idle Stop), the
3342
+ # parser's (a late signal, resumed), the dispatcher's (Stop as the cell's own
3343
+ # outcome) — and the loop around them has the last word: an interrupt that
3344
+ # escapes an iteration ends that iteration, never the loop, and one that escapes
3345
+ # the loop itself re-enters it through the `carmar_serve_again` restart from a
3346
+ # global calling handler. Before 2026-09-16 the parse step had no handler at all,
3347
+ # and a late Stop signal raised there unwound the whole worker script into R's
3348
+ # top-level prompt — where the `options(error=)` guard ended the process, or,
3349
+ # without it, R sat alive and silent behind commands it read as comments.
3350
+ #
3351
+ # Every command is handled in the worker because every one of them needs the
3352
+ # SESSION — the environment, the search path, the working directory. Even
3353
+ # `files` and `import`: import assigns into the environment, and both must
3354
+ # see the same wd and `~` the user's own code sees. The cost is that they
3355
+ # queue behind a running cell, which is also the correct ordering.
3356
+ #
3357
+ # A handler that throws must cost its own command and nothing else: the
3358
+ # session behind it holds the user's unsaved work, and losing it to a
3359
+ # malformed `view` request is not a trade anyone would accept. The failure
3360
+ # comes back as an `error` FIELD on a frame carrying the request's id, so
3361
+ # the caller sees a reason instead of waiting out a timeout.
3362
+ #
3363
+ # `interrupt` IS caught below, but only as a last resort: Stop is delivered
3364
+ # as an interrupt condition and the handlers that can be interrupted (exec,
3365
+ # view) catch it themselves, with meaning. The outer handler exists for the
3366
+ # narrow window where one lands outside those — an uncaught interrupt ends
3367
+ # the worker script, and the supervisor treats a dead worker as fatal.
3368
+ carmar_dispatch <- function(cmd) {
3369
+ if (identical(cmd$type, "exec")) {
3370
+ if (is.null(cmd$document)) run_cell(cmd$id, cmd$source, cmd$dims, cmd$srcname)
3371
+ else run_document_cell(cmd$id, cmd$source, cmd$document, cmd$dims, cmd$srcname)
3372
+ }
3373
+ if (identical(cmd$type, "debug_breaks")) emit_debug_breaks(cmd$id, cmd$file, cmd$lines)
3374
+ if (identical(cmd$type, "env")) emit_env(cmd$id)
3375
+ if (identical(cmd$type, "obj")) emit_obj(cmd$id, cmd$name)
3376
+ if (identical(cmd$type, "struct")) emit_struct(cmd$id, cmd$name, cmd$path)
3377
+ if (identical(cmd$type, "parse")) emit_parse(cmd$id, cmd$source)
3378
+ if (identical(cmd$type, "format")) emit_format(cmd$id, cmd$source)
3379
+ if (identical(cmd$type, "doctor")) emit_doctor(cmd$id)
3380
+ if (identical(cmd$type, "complete")) emit_complete(cmd$id, cmd$line, cmd$cursor,
3381
+ fn = cmd$fn, data = cmd$data)
3382
+ if (identical(cmd$type, "packages")) emit_packages(cmd$id, cmd$scope)
3383
+ if (identical(cmd$type, "package_action")) emit_package_action(cmd$id, cmd$action, cmd$name, cmd$lib)
3384
+ if (identical(cmd$type, "package_help")) emit_package_help(cmd$id, cmd$name)
3385
+ if (identical(cmd$type, "project_status")) emit_project_status(cmd$id)
3386
+ if (identical(cmd$type, "project_action")) emit_project_action(cmd$id, cmd$action)
3387
+ if (identical(cmd$type, "help")) emit_help(cmd$id, cmd$topic)
3388
+ if (identical(cmd$type, "hover")) emit_hover(cmd$id, cmd$name)
3389
+ if (identical(cmd$type, "wd")) emit_wd(cmd$id, cmd$path)
3390
+ if (identical(cmd$type, "files")) emit_files(cmd$id, cmd$path, isTRUE(cmd$all))
3391
+ if (identical(cmd$type, "sniff")) emit_sniff(cmd$id, cmd$path, cmd$opts)
3392
+ if (identical(cmd$type, "import")) emit_import(cmd$id, cmd$path, cmd$name)
3393
+ if (identical(cmd$type, "readfile")) emit_readfile(cmd$id, cmd$path)
3394
+ if (identical(cmd$type, "writefile")) emit_writefile(cmd$id, cmd$path, cmd$text, cmd$expected, cmd$encoding, cmd$base64)
3395
+ if (identical(cmd$type, "writefiles_atomic")) emit_writefiles_atomic(cmd$id, cmd$files)
3396
+ if (identical(cmd$type, "mkdir")) emit_mkdir(cmd$id, cmd$path)
3397
+ if (identical(cmd$type, "renamepath")) emit_renamepath(cmd$id, cmd$path, cmd$to)
3398
+ if (identical(cmd$type, "deletepath")) emit_deletepath(cmd$id, cmd$paths, isTRUE(cmd$recursive))
3399
+ if (identical(cmd$type, "copypath")) emit_copypath(cmd$id, cmd$path, cmd$to)
3400
+ if (identical(cmd$type, "revealpath")) emit_revealpath(cmd$id, cmd$path)
3401
+ # `rows` is the pre-paging spelling of `limit`; old clients keep working.
3402
+ if (identical(cmd$type, "view")) emit_view(cmd$id, cmd$name,
3403
+ offset = cmd$offset,
3404
+ limit = cmd$limit %||% cmd$rows,
3405
+ sort = cmd$sort,
3406
+ desc = isTRUE(cmd$desc),
3407
+ col_offset = cmd$colOffset,
3408
+ col_limit = cmd$colLimit,
3409
+ query = cmd$query,
3410
+ filters = cmd$filters)
3411
+ if (identical(cmd$type, "colstats")) emit_colstats(cmd$id, cmd$name, cmd$column,
3412
+ query = cmd$query,
3413
+ filters = cmd$filters)
3414
+ if (identical(cmd$type, "rm")) emit_rm(cmd$id, cmd$names)
3415
+ invisible(NULL)
3416
+ }
3417
+ # An exec that dies OUTSIDE run_cell's own handlers must still end in a
3418
+ # `done`: the browser settles a cell only on its done frame, and the
3419
+ # supervisor retires the route (and frees the worker queue) on the same
3420
+ # signal — an `{"type":"exec", error}` frame satisfied neither, so one
3421
+ # malformed command wedged every later run behind it, permanently.
3422
+ #
3423
+ # The interrupt handler is the same guarantee for Stop: run_cell catches
3424
+ # interrupts around the eval, but one landing in its epilogue (unlink,
3425
+ # flush, the emit itself) used to escape this loop, end the worker script,
3426
+ # and take the supervisor's event loop down with it.
3427
+ carmar_fail_frame <- function(cmd, text, outcome = "error") {
3428
+ id_ok <- is.character(cmd$id) && length(cmd$id) == 1L
3429
+ if (identical(cmd$type, "exec")) {
3430
+ if (id_ok) emit(list(type = "done", id = cmd$id, status = outcome,
3431
+ message = text))
3432
+ } else {
3433
+ emit(list(type = cmd$type, id = if (id_ok) cmd$id else NULL,
3434
+ error = text))
3435
+ }
3436
+ }
3437
+
3438
+ #' Serve one command. Returns "eof" when the supervisor has gone, "shutdown"
3439
+ #' when it said so, NULL after a command or a line that was not one.
3440
+ #' @param con The command connection (the console in interactive mode).
3441
+ carmar_serve_one <- function(con) {
3442
+ line <- read_command(con)
3443
+ if (length(line) == 0L) return("eof") # EOF: supervisor went away
3444
+ if (is.na(line) || !nzchar(trimws(line))) return(NULL)
3445
+ cmd <- carmar_parse_command(line)
3446
+ if (is.null(cmd)) return(NULL)
3447
+ if (identical(cmd$type, "shutdown")) return("shutdown")
3448
+ # The acknowledgement: the supervisor wrote this command to an idle worker and
3449
+ # can time the reply to this frame. A command line the console reader was
3450
+ # half-way through when a late signal landed is torn beyond what any handler
3451
+ # here can mend (readline discards the half it had; the rest arrives as a
3452
+ # line that is not a command), and without this frame the supervisor could
3453
+ # only wait forever — an exec has no deadline, by design. With it, a command
3454
+ # that is never acknowledged is failed with a sentence and the queue moves on.
3455
+ if (is.character(cmd$id) && length(cmd$id) == 1L && !is.na(cmd$id)) emit(list(type = "ack", id = cmd$id))
3456
+ tryCatch(carmar_dispatch(cmd),
3457
+ error = function(e) carmar_fail_frame(cmd, paste("command failed:", conditionMessage(e))),
3458
+ # Cleanup interrupts mean the same thing as interrupts during eval.
3459
+ interrupt = function(i) carmar_fail_frame(cmd, "Execution interrupted", "interrupted"))
3460
+ NULL
3461
+ }
3462
+
3463
+ #' The loop. An interrupt that escapes an iteration's own handlers costs that
3464
+ #' iteration and nothing else.
3465
+ #' @param con The command connection.
3466
+ #' @return "eof" or "shutdown", invisibly.
3467
+ carmar_serve <- function(con) {
3468
+ repeat {
3469
+ outcome <- tryCatch(carmar_serve_one(con), interrupt = function(i) NULL)
3470
+ if (identical(outcome, "eof") || identical(outcome, "shutdown")) return(invisible(outcome))
3471
+ }
3472
+ }
3473
+
3474
+ # The last word. A global calling handler runs only for an interrupt no handler
3475
+ # above it took (R walks the stack from the innermost handler outwards and stops
3476
+ # at the first exiting one), so it is reached only from the few evaluations the
3477
+ # loop spends between two iterations — and from there it re-enters the loop,
3478
+ # with the session intact. With no loop to re-enter (boot, shutdown) it ends
3479
+ # the process, as the `options(error=)` guard does for an error: a worker at
3480
+ # R's top-level prompt is a zombie the supervisor cannot tell from an idle one.
3481
+ globalCallingHandlers(interrupt = function(i) {
3482
+ if (!is.null(findRestart("carmar_serve_again"))) invokeRestart("carmar_serve_again")
3483
+ quit(save = "no", status = 70L)
3484
+ })
3485
+ repeat {
3486
+ outcome <- withRestarts(carmar_serve(con), carmar_serve_again = function() NULL)
3487
+ if (!is.null(outcome)) break
3488
+ }
3489
+
3490
+ # stdin() is R's console, not ours to close. And an interactive R does not
3491
+ # exit when the script does — it would sit at the top-level prompt waiting for
3492
+ # input until the supervisor's grace timeout killed it — so the shutdown that
3493
+ # broke the loop ends the process here, explicitly.
3494
+ if (identical(WORKER_MODE, "interactive")) quit(save = "no") else close(con)
3495
+
3496
+ })