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,671 @@
1
+ #
2
+ # Kernel — owns the R worker process. Deliberately knows nothing about HTTP.
3
+ #
4
+ # The whole point of the two-process split: this code never evaluates user
5
+ # source, so it stays responsive while the worker is blocked inside a 40-second
6
+ # bootstrap. Interrupting is then a signal to a process, not a message the
7
+ # blocked process has to notice.
8
+
9
+ suppressPackageStartupMessages(library(jsonlite))
10
+
11
+ #' Which R should host the session?
12
+ #'
13
+ #' Not "whatever `Rscript` is first on PATH" — that is how this shipped running
14
+ #' Homebrew's R 4.6.1 with 176 packages while the user's own library (the CRAN
15
+ #' framework build RStudio uses, 1241 packages including tna and TraMineR) sat
16
+ #' unused. An IDE that cannot see your packages is not your IDE.
17
+ #'
18
+ #' Order: an explicit `CARMAR_RSCRIPT`, then the macOS framework R, then PATH.
19
+ #'
20
+ #' @return Path to an Rscript binary.
21
+ #' `explicit` is an argument rather than only an env read so serve.R can pass
22
+ #' the RESOLVED value (a user's rscript_path from settings, or the env var when
23
+ #' a deployment set one). Defaulting to the env var keeps every existing call
24
+ #' correct without changing it.
25
+ detect_rscript <- function(explicit = Sys.getenv("CARMAR_RSCRIPT", "")) {
26
+ if (nzchar(explicit) && file.exists(explicit)) return(explicit)
27
+ # The same ladder tools/app/launch.sh walks, and for the same reason: this
28
+ # process may itself have been started by a Finder launch, whose PATH is
29
+ # /usr/bin:/bin:/usr/sbin:/sbin — so Sys.which() finds a CRAN framework
30
+ # install and misses Homebrew, rig, conda and Posit entirely. PATH is the
31
+ # LAST rung, not the second.
32
+ known <- c(
33
+ "/Library/Frameworks/R.framework/Versions/Current/Resources/bin/Rscript",
34
+ "/opt/homebrew/bin/Rscript",
35
+ "/usr/local/bin/Rscript",
36
+ "/opt/local/bin/Rscript",
37
+ "/usr/bin/Rscript"
38
+ )
39
+ for (cand in known) if (file.exists(cand)) return(cand)
40
+ # `Versions/Current` is a symlink CRAN maintains and other installers do
41
+ # not; newest version first.
42
+ versioned <- sort(Sys.glob(
43
+ "/Library/Frameworks/R.framework/Versions/*/Resources/bin/Rscript"), decreasing = TRUE)
44
+ if (length(versioned)) return(versioned[[1]])
45
+ unname(Sys.which("Rscript"))
46
+ }
47
+
48
+ #' The full R binary beside an Rscript, for the interactive worker.
49
+ #'
50
+ #' The debugger needs `R --interactive`: only then is the CONSOLE the stdin
51
+ #' pipe, which is what lets a native `browser()` prompt read step commands the
52
+ #' supervisor sends. `Rscript` cannot do this — under it the console is the
53
+ #' script FILE, and a `browser()` reads the script's own remaining lines as
54
+ #' debug input (measured: it consumed the lines after the call and never
55
+ #' touched stdin).
56
+ #'
57
+ #' @param rscript Path to the Rscript binary actually chosen.
58
+ #' @return Path to the sibling R binary, or "" when it does not exist.
59
+ detect_r_binary <- function(rscript) {
60
+ candidate <- file.path(dirname(rscript),
61
+ if (.Platform$OS.type == "windows") "R.exe" else "R")
62
+ if (nzchar(rscript) && file.exists(candidate)) candidate else ""
63
+ }
64
+
65
+ #' The first expression an interactive worker runs on macOS.
66
+ #'
67
+ #' Command-line R promotes itself to a foreground LaunchServices application
68
+ #' when its Aqua/AppKit support initializes. On macOS 26 that gives every
69
+ #' document worker a generic `exec` Dock tile. A packaged CarmaR kernel carries
70
+ #' a tiny native library beside kernel.R; calling it before worker.R is
71
+ #' sourced registers this process as BackgroundOnly first. Distributions may
72
+ #' carry either an architecture-specific marker (R packages) or one universal
73
+ #' marker (application bundles and source checkouts). The process itself
74
+ #' must make the transition — a UIElement parent does not confer its activation
75
+ #' policy on an exec'd child.
76
+ #'
77
+ #' Source checkouts and non-macOS packages may not carry the compiled library;
78
+ #' in those cases this is deliberately a no-op and kernel startup is unchanged.
79
+ #'
80
+ #' @param worker_path Path to the worker boot script.
81
+ #' @param sysname Injectable platform name for source-only tests.
82
+ #' @param arch Injectable R process architecture for source-only tests.
83
+ #' @return A one-line R expression, or "" when no marker is available.
84
+ macos_background_boot <- function(worker_path,
85
+ sysname = unname(Sys.info()[["sysname"]]),
86
+ arch = R.version$arch) {
87
+ if (!identical(sysname, "Darwin")) return("")
88
+ # OPT-IN, and defaulting off is a reliability decision. TransformProcessType
89
+ # hides the worker's Dock icon, but on a GUI-launched process (macOS 26) it
90
+ # leaves R's console/event handling in a state where a plain readLines(stdin)
91
+ # — every single command the supervisor sends — can block forever in
92
+ # R_checkActivityEx, wedging the whole session at 0% CPU with Stop unable to
93
+ # reach it. Foreground R, which is what RStudio runs, never does this. So a
94
+ # visible Dock icon is the price of a kernel that answers; set
95
+ # CARMAR_MARK_BACKGROUND=1 to restore Dock hiding once a transform that does
96
+ # not wedge the console read is found.
97
+ if (!identical(Sys.getenv("CARMAR_MARK_BACKGROUND", "0"), "1")) return("")
98
+ marker <- Sys.getenv("CARMAR_BACKGROUND_LIBRARY", "")
99
+ if (!nzchar(marker)) {
100
+ suffix <- if (grepl("^(aarch64|arm64)", arch, ignore.case = TRUE)) {
101
+ "arm64"
102
+ } else if (grepl("x86_64|x86-64|amd64", arch, ignore.case = TRUE)) {
103
+ "x86_64"
104
+ } else ""
105
+ beside <- dirname(worker_path)
106
+ candidates <- c(
107
+ if (nzchar(suffix)) file.path(
108
+ beside, paste0("carmar-background-", suffix, ".dylib")),
109
+ file.path(beside, "carmar-background.dylib"))
110
+ found <- candidates[file.exists(candidates)]
111
+ marker <- if (length(found)) found[[1L]] else candidates[[1L]]
112
+ }
113
+ if (!file.exists(marker)) return("")
114
+ marker <- encodeString(normalizePath(marker, mustWork = TRUE), quote = '"')
115
+ # TransformProcessType can report paramErr when the unbundled executable is
116
+ # already background-only; the call still causes the early LaunchServices
117
+ # registration we need. Classification, not that legacy status code, is the
118
+ # contract, so the result is intentionally ignored.
119
+ sprintf('local({dyn.load(%s);.C("carmar_mark_background",result=integer(1));invisible(NULL)})',
120
+ marker)
121
+ }
122
+
123
+ #' Start a worker process.
124
+ #'
125
+ #' @param worker_path Path to worker.R.
126
+ #' @param sentinel Random per-session token framing control lines. Generated
127
+ #' if absent; user code cannot guess it, so it cannot forge a frame.
128
+ #' @return A kernel handle: list(proc, sentinel, buffer).
129
+ #' @param env_extra Named character vector of environment variables to add for
130
+ #' the worker — e.g. `c(CARMAR_ROOT = "/srv/project")`, which confines every
131
+ #' file command to that subtree. Managed deployments set it; tests set it to
132
+ #' prove the confinement holds.
133
+ #' A UTF-8 character locale, whatever the launcher handed us.
134
+ #'
135
+ #' The desktop app is started by launchd, which supplies no locale at all, so
136
+ #' R falls back to C — and a C locale is not "English", it is "no character
137
+ #' encoding". In that state jsonlite escapes every high byte of a string whose
138
+ #' declared encoding is unknown, so `35 – 1158` reaches the browser as
139
+ #' `35 <e2><80><93> 1158`, a column called `région` mangles the same way, and R
140
+ #' source containing a non-ASCII identifier does not even parse.
141
+ #'
142
+ #' Both ENDS need this. The supervisor calls it for itself (it parses and
143
+ #' re-encodes every frame on the wire); `kernel_start` puts it in each child's
144
+ #' environment. Fixing only one end fixes nothing: the mangling simply moves.
145
+ #'
146
+ #' Surgical: LC_CTYPE alone, so the user's own locale keeps deciding number and
147
+ #' date formatting, and only when what we have is not already UTF-8 — a
148
+ #' deliberate de_DE.UTF-8 is never overridden.
149
+ #'
150
+ #' @return The locale name now in force for LC_CTYPE.
151
+ utf8_ctype <- function() {
152
+ current <- Sys.getlocale("LC_CTYPE")
153
+ if (grepl("utf-?8", current, ignore.case = TRUE)) return(current)
154
+ for (candidate in c("C.UTF-8", "en_US.UTF-8", "UTF-8")) {
155
+ if (nzchar(suppressWarnings(Sys.setlocale("LC_CTYPE", candidate)))) return(candidate)
156
+ }
157
+ current # nothing available: carry on as-is
158
+ }
159
+
160
+ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(),
161
+ env_extra = character(), interactive = FALSE) {
162
+ stopifnot(file.exists(worker_path), nzchar(rscript))
163
+ if (is.null(sentinel)) {
164
+ sentinel <- paste(sample(c(letters, 0:9), 24L, replace = TRUE), collapse = "")
165
+ }
166
+ # An `--interactive` R is (Unix only) per the R manual, and it is only worth
167
+ # having when the sibling R binary exists. Falling back to batch keeps every
168
+ # session working; only the debugger is absent, and the worker's ready frame
169
+ # says which mode it booted in so nothing has to guess.
170
+ r_binary <- if (interactive && .Platform$OS.type != "windows")
171
+ detect_r_binary(rscript) else ""
172
+ mode <- if (nzchar(r_binary)) "interactive" else "batch"
173
+ # A SECOND token, deliberately not the sentinel. Commands sent to an
174
+ # interactive worker are echoed back on stdout by R's console reader, so
175
+ # they must be recognizable for scrubbing — but if they carried the frame
176
+ # sentinel, their echo would parse as a control frame FROM the worker. Two
177
+ # independent tokens make the two directions structurally non-confusable.
178
+ cmdtag <- paste(sample(c(letters, 0:9), 24L, replace = TRUE), collapse = "")
179
+ # The supervisor is itself an R process, so its R_HOME / R_LIBS* point at the
180
+ # R that launched it. Inheriting those into a DIFFERENT R installation aims
181
+ # the child at the wrong tree and it never starts. Strip them and let the
182
+ # chosen binary discover its own.
183
+ parent_env <- Sys.getenv()
184
+ clean_env <- parent_env[!grepl("^R_(HOME|LIBS|LIBS_USER|LIBS_SITE|PROFILE|ENVIRON|DOC_DIR|INCLUDE_DIR|SHARE_DIR)",
185
+ names(parent_env))]
186
+ # ── the child must speak UTF-8, whatever launched the parent ────────────
187
+ # The desktop launcher inherits launchd's C locale, and a child R in a C
188
+ # locale is not merely English — it cannot represent non-ASCII at all:
189
+ #
190
+ # * a string literal in worker.R with an en dash parses to encoding
191
+ # "unknown", and jsonlite serialises it as `35 <e2><80><93> 1158`, which
192
+ # is what the variables panel showed;
193
+ # * a data frame with a column called `région` mangles the same way;
194
+ # * a source file containing a non-ASCII IDENTIFIER does not even parse.
195
+ #
196
+ # `encoding = "UTF-8"` on the pipes below fixes only the supervisor's READING
197
+ # of the wire. This fixes the child's PRODUCING of it, which is the half that
198
+ # was missing.
199
+ #
200
+ # Surgical on purpose: only LC_CTYPE (the character encoding), so a user's
201
+ # own locale keeps deciding number and date formatting; only when the
202
+ # inherited locale is not already UTF-8, so a deliberate de_DE.UTF-8 is never
203
+ # overridden; and LC_ALL is DROPPED when it is the non-UTF-8 culprit, because
204
+ # LC_ALL outranks LC_CTYPE and would otherwise win silently.
205
+ utf8_locale <- function(x) grepl("utf-?8", x, ignore.case = TRUE)
206
+ # `Sys.getenv()` is a named CHARACTER VECTOR, and `x[["missing"]]` on one
207
+ # throws "subscript out of bounds" — it does not return NULL the way a list
208
+ # does. LC_ALL is unset on most machines, so reading it directly errored on
209
+ # the common path and took the kernel down at startup.
210
+ from_env <- function(name) if (name %in% names(clean_env)) clean_env[[name]] else ""
211
+ lc_all <- from_env("LC_ALL")
212
+ # Decide from what the CHILD inherits - its ENVIRONMENT - never from
213
+ # Sys.getlocale(), which is the SUPERVISOR's RUNTIME locale. macOS boots a
214
+ # GUI-launched supervisor with a runtime C.UTF-8 that is NEVER in the env, so
215
+ # it never reaches the child; the child sees only these variables, and with
216
+ # LC_ALL/LC_CTYPE/LANG all unset it falls back to bare C. There a single
217
+ # multi-byte character in a command line (an en dash in a comment such as
218
+ # `orders 0-5`, an accent, an emoji) wedges the interactive console reader and
219
+ # freezes the kernel. Reading the runtime locale here let the guard skip in
220
+ # exactly that case, which is why the packaged app hung on the first non-ASCII
221
+ # chunk while a shell or RStudio (both of which export a UTF-8 locale) did not.
222
+ effective <- if (nzchar(lc_all)) lc_all
223
+ else if (nzchar(from_env("LC_CTYPE"))) from_env("LC_CTYPE")
224
+ else from_env("LANG")
225
+ if (!utf8_locale(effective)) {
226
+ # LC_ALL outranks LC_CTYPE, so a non-UTF-8 LC_ALL would win silently.
227
+ if (nzchar(lc_all) && !utf8_locale(lc_all)) {
228
+ clean_env <- clean_env[names(clean_env) != "LC_ALL"]
229
+ }
230
+ clean_env[["LC_CTYPE"]] <- utf8_ctype()
231
+ }
232
+
233
+ if (length(env_extra)) {
234
+ stopifnot(is.character(env_extra), !is.null(names(env_extra)))
235
+ clean_env <- c(clean_env[setdiff(names(clean_env), names(env_extra))], env_extra)
236
+ }
237
+
238
+ if (identical(mode, "interactive")) {
239
+ # TERM=dumb: readline's terminal probe writes an escape sequence
240
+ # ("\033[?1034h") to stdout before anything else, and a dumb terminal is
241
+ # the documented way to keep it out of the protocol stream.
242
+ clean_env[["TERM"]] <- "dumb"
243
+ # The worker cannot read argv for these — `R --interactive` takes no
244
+ # script argument, the worker is booted by a sys.source line fed through
245
+ # stdin below — so they travel in the environment instead.
246
+ clean_env[["CARMAR_SENTINEL"]] <- sentinel
247
+ clean_env[["CARMAR_CMD_TAG"]] <- cmdtag
248
+ clean_env[["CARMAR_WORKER_MODE"]] <- "interactive"
249
+ clean_env[["CARMAR_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
250
+ }
251
+ spawn_bin <- if (identical(mode, "interactive")) r_binary else rscript
252
+ spawn_args <- if (identical(mode, "interactive")) {
253
+ # --no-echo suppresses the "> " prompt; the input ECHO it does not suppress
254
+ # is scrubbed in kernel_poll by the cmdtag / pending-echo machinery.
255
+ # --no-readline FIRST: R ignores it after --interactive, and with readline
256
+ # R echoes each line read — on Linux as a scrolled "<" + tail that carries no
257
+ # cmdtag (host/engine-r.mjs has the measurement).
258
+ c("--no-readline", "--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file")
259
+ } else {
260
+ # --vanilla, NOT --no-init-file: the user's .Rprofile/.Renviron are part of
261
+ # their R (library paths, repos, options). Only history and saved workspaces
262
+ # are suppressed, which is what a fresh session wants.
263
+ c("--no-save", "--no-restore", "--no-site-file", worker_path, sentinel)
264
+ }
265
+ proc <- processx::process$new(
266
+ spawn_bin,
267
+ spawn_args,
268
+ stdin = "|", stdout = "|", stderr = "|",
269
+ env = clean_env,
270
+ supervise = TRUE,
271
+ # The desktop launcher may itself inherit the C locale from launchd.
272
+ # Decode the protocol pipes by their actual wire encoding instead of the
273
+ # supervisor's locale, or non-ASCII output can be escaped before framing.
274
+ encoding = "UTF-8"
275
+ )
276
+ # processx's line reader waits for a complete line before releasing it. A
277
+ # large JSON view/plot frame can therefore sit in its internal buffer long
278
+ # enough to starve the supervisor. Keep our own incremental framing state
279
+ # in an environment so it remains mutable through R's copied list handle.
280
+ io <- new.env(parent = emptyenv())
281
+ io$out <- ""
282
+ io$err <- ""
283
+ k <- list(proc = proc, sentinel = sentinel, rscript = rscript, io = io,
284
+ mode = mode, cmdtag = cmdtag)
285
+ if (identical(mode, "interactive")) {
286
+ # The boot line replaces worker-boot.R: same sys.source, same speed
287
+ # rationale (parse the ~2,000-line file in one pass instead of feeding it
288
+ # through the REPL reader). worker.R itself is sourced, not the shim —
289
+ # the shim's only job was resolving its own directory from --file=,
290
+ # which CARMAR_WORKER_DIR now carries.
291
+ real_worker <- file.path(dirname(worker_path), "worker.R")
292
+ if (!file.exists(real_worker)) real_worker <- worker_path
293
+ worker_boot <- sprintf('sys.source("%s", envir = globalenv(), keep.source = FALSE)',
294
+ encodeString(normalizePath(real_worker)))
295
+ boot <- paste(Filter(nzchar, c(macos_background_boot(worker_path), worker_boot)),
296
+ collapse = ";")
297
+ kernel_console(k, boot)
298
+ }
299
+ k
300
+ }
301
+
302
+ #' Send one RAW console line to an interactive worker.
303
+ #'
304
+ #' This is the debugger's channel: while the worker is paused at a Browse
305
+ #' prompt, `n` / `s` / `f` / `c` and debug-console expressions are ordinary
306
+ #' console lines, not NDJSON. Measured under processx pipes, R does NOT echo
307
+ #' console input back (it does under a shell pipe with a controlling tty —
308
+ #' which is only the test bench, never the shipped spawn), so nothing here
309
+ #' needs scrubbing; the tagged-command scrub in kernel_poll stays as the
310
+ #' belt for the high-volume NDJSON path.
311
+ #'
312
+ #' @param k Kernel handle (mode "interactive").
313
+ #' @param line One line of text, no newline.
314
+ #' @return Invisibly TRUE.
315
+ kernel_console <- function(k, line) {
316
+ stopifnot(identical(k$mode, "interactive"),
317
+ is.character(line), length(line) == 1L, !grepl("\n", line, fixed = TRUE))
318
+ kernel_write(k, paste0(line, "\n"))
319
+ }
320
+
321
+ #' Send a cell to the worker.
322
+ #'
323
+ #' @param k Kernel handle.
324
+ #' @param id Cell id.
325
+ #' @param source R source text.
326
+ #' @param dims Optional list(width, height, res) in pixels/dpi for this run's
327
+ #' graphics device — the equivalent of a chunk's `fig.width`/`fig.height`.
328
+ #' One hardcoded size is never right for every plot: a seqplot with a
329
+ #' six-column legend needs a different device than a scatter.
330
+ #' @return Invisibly TRUE.
331
+ kernel_exec <- function(k, id, source, dims = NULL) {
332
+ stopifnot(is.character(id), is.character(source))
333
+ cmd <- list(type = "exec", id = id, source = source)
334
+ if (!is.null(dims)) cmd$dims <- dims
335
+ kernel_command(k, toJSON(cmd, auto_unbox = TRUE))
336
+ }
337
+
338
+ # R's interactive console reader wedges the whole process on input lines
339
+ # somewhere past 40,000 bytes (measured: 40,000 passed, 45,000 hung, and the
340
+ # hang is a hang, not an error). Anything bigger travels through a file
341
+ # instead. Batch workers have no such limit and never spill.
342
+ MAX_CONSOLE_LINE <- 32000L
343
+
344
+ #' Deliver one JSON command to the worker, however it must travel.
345
+ #'
346
+ #' Batch mode: the bare NDJSON line, exactly as always. Interactive mode: the
347
+ #' line is prefixed `#<cmdtag> ` — a comment, so the one failure mode where it
348
+ #' could reach R's raw top level (a catastrophic dispatch-loop abort) makes it
349
+ #' inert instead of evaluated, and so its console echo is self-identifying for
350
+ #' the scrubber in kernel_poll. A command too long for the console reader is
351
+ #' written to a 0600 temp file and replaced on the wire by a `cmdfile` stub;
352
+ #' the worker reads the file, deletes it, and dispatches its content.
353
+ #'
354
+ #' @param k Kernel handle.
355
+ #' @param json One complete JSON command, as text, no newline.
356
+ #' @return Invisibly TRUE.
357
+ kernel_command <- function(k, json) {
358
+ if (!identical(k$mode, "interactive")) {
359
+ return(kernel_write(k, paste0(json, "\n")))
360
+ }
361
+ if (nchar(json, type = "bytes") > MAX_CONSOLE_LINE) {
362
+ spill <- tempfile("carmar-cmd-", fileext = ".json")
363
+ writeLines(json, spill, useBytes = TRUE)
364
+ Sys.chmod(spill, mode = "0600")
365
+ json <- toJSON(list(type = "cmdfile", path = spill), auto_unbox = TRUE)
366
+ }
367
+ kernel_write(k, paste0("#", k$cmdtag, " ", json, "\n"))
368
+ }
369
+
370
+ #' Write a whole command to the worker's stdin, however long it is.
371
+ #'
372
+ #' `processx::write_input()` is a NON-BLOCKING write: it puts what fits in the
373
+ #' operating system's pipe buffer and RETURNS THE REST as a raw vector. Ignoring
374
+ #' that return value silently truncated every command larger than the buffer —
375
+ #' the worker then waited forever for the end of a line that was never sent, and
376
+ #' the browser waited for an answer that never came. Anything small worked, so
377
+ #' the failure looked like a size-dependent hang rather than a dropped write:
378
+ #' `writefile` refused a 50 KB script, and a knitted report carrying one plot
379
+ #' never reached R at all.
380
+ #'
381
+ #' The loop is bounded. A worker busy inside a long-running cell does not drain
382
+ #' its stdin, so a command sent at that moment can genuinely have nowhere to go;
383
+ #' after `timeout` seconds this says so instead of blocking the supervisor's
384
+ #' event loop for the rest of the session.
385
+ #'
386
+ #' @param k Kernel handle.
387
+ #' @param text One complete command line, newline-terminated.
388
+ #' @param timeout Seconds to keep trying before giving up.
389
+ #' @return Invisibly TRUE.
390
+ kernel_write <- function(k, text, timeout = 15) {
391
+ left <- k$proc$write_input(text)
392
+ deadline <- Sys.time() + timeout
393
+ while (length(left) > 0L) {
394
+ if (Sys.time() > deadline) {
395
+ stop(sprintf("R is not reading its input — %d bytes of this command could not be sent.",
396
+ length(left)), call. = FALSE)
397
+ }
398
+ Sys.sleep(0.01)
399
+ left <- k$proc$write_input(left)
400
+ }
401
+ invisible(TRUE)
402
+ }
403
+
404
+ #' Send any command to the worker.
405
+ #'
406
+ #' `kernel_exec` is this with type "exec"; the IDE's panes (environment,
407
+ #' packages, help, working directory, parse-completeness) all ride the same
408
+ #' channel, so there is one queue and one ordering.
409
+ #'
410
+ #' @param k Kernel handle.
411
+ #' @param cmd Named list with at least `type` and `id`.
412
+ #' @return Invisibly TRUE.
413
+ kernel_send <- function(k, cmd) {
414
+ stopifnot(is.list(cmd), !is.null(cmd$type))
415
+ kernel_command(k, toJSON(cmd, auto_unbox = TRUE))
416
+ }
417
+
418
+ #' Send a raw line to the worker, bypassing JSON encoding.
419
+ #'
420
+ #' Exists for the security suite: the frames worth testing are exactly the ones
421
+ #' `toJSON` cannot produce — a bare number, an array, `{"type":5}`. A worker
422
+ #' that dies on those takes the user's session with it, so they have to be
423
+ #' sendable.
424
+ #'
425
+ #' @param k Kernel handle.
426
+ #' @param line One line of text; a newline is appended if absent.
427
+ #' @return Invisibly TRUE.
428
+ kernel_send_raw <- function(k, line) {
429
+ stopifnot(is.character(line), length(line) == 1L)
430
+ # Interactive mode still tags the line: the point of these tests is what the
431
+ # worker's PARSER does with malformed JSON, and the tag is what routes the
432
+ # line to that parser (and keeps its echo scrubbable) rather than leaving it
433
+ # to be misread as console input.
434
+ kernel_command(k, sub("\n$", "", line))
435
+ }
436
+
437
+ #' Interrupt whatever the worker is doing.
438
+ #'
439
+ #' SIGINT rather than SIGKILL: the worker catches it, reports `interrupted`,
440
+ #' and keeps its global environment, so the session is not lost.
441
+ #'
442
+ #' @param k Kernel handle.
443
+ #' @return Invisibly TRUE.
444
+ kernel_interrupt <- function(k) {
445
+ # The worker is the leader of its own process group (processx setpgid), so
446
+ # signalling the GROUP reaches the children too. processx's interrupt()
447
+ # signals the R pid alone — and R inside system()/system2() waits on a
448
+ # child that never heard, while libc's system() ignores SIGINT in the
449
+ # caller for the duration: measured, `system("sleep 60")` could not be
450
+ # stopped at all and wedged every later Stop. A terminal's ^C goes to the
451
+ # whole foreground group; so does this. Windows has no groups here.
452
+ if (.Platform$OS.type != "windows") {
453
+ pid <- tryCatch(k$proc$get_pid(), error = function(e) NA_integer_)
454
+ if (length(pid) == 1L && !is.na(pid) && pid > 0L) {
455
+ try(system2("kill", c("-INT", "--", paste0("-", pid)), stdout = FALSE, stderr = FALSE),
456
+ silent = TRUE)
457
+ }
458
+ }
459
+ try(k$proc$interrupt(), silent = TRUE)
460
+ invisible(TRUE)
461
+ }
462
+
463
+ #' Ask the worker to exit, then make sure it did.
464
+ #'
465
+ #' @param k Kernel handle.
466
+ #' @param grace Seconds to wait before killing.
467
+ #' @return Invisibly TRUE.
468
+ kernel_stop <- function(k, grace = 2) {
469
+ try(kernel_command(k, "{\"type\":\"shutdown\"}"), silent = TRUE)
470
+ k$proc$wait(timeout = grace * 1000)
471
+ if (k$proc$is_alive()) k$proc$kill()
472
+ invisible(TRUE)
473
+ }
474
+
475
+ #' Stop a detached task and its descendants, including separate process groups.
476
+ #' processx's inherited tree marker also finds orphaned descendants. A plain
477
+ #' process kill only reaches that process's own group; an engine started by
478
+ #' processx has a different group. Always finish with the root-group kill.
479
+ #' @param k Kernel handle for the task being stopped.
480
+ #' @return Invisibly TRUE.
481
+ kernel_kill_tree <- function(k) {
482
+ try(k$proc$kill_tree(close_connections = FALSE), silent = TRUE)
483
+ try(k$proc$kill(), silent = TRUE)
484
+ invisible(TRUE)
485
+ }
486
+
487
+ #' Drain whatever the worker has produced since the last call.
488
+ #'
489
+ #' Blocks at most `timeout_ms`, so a caller can poll this from an event loop
490
+ #' without ever stalling. Lines carrying the sentinel are control frames;
491
+ #' everything else on stdout is the user's own output.
492
+ #'
493
+ #' @param k Kernel handle.
494
+ #' @param timeout_ms Maximum block, milliseconds.
495
+ #' @return List of events, each with `$type` in "stdout"/"stderr"/"stream"/
496
+ #' "done"/"ready".
497
+ kernel_poll <- function(k, timeout_ms = 50L) {
498
+ k$proc$poll_io(timeout_ms)
499
+ # Drain available bytes, not complete processx lines. Control frames can be
500
+ # hundreds of KB; incremental draining prevents the child from blocking on
501
+ # a full pipe while the parent waits for that same frame's newline.
502
+ take_lines <- function(field, chunk, flush = FALSE) {
503
+ incoming <- if (length(chunk)) paste0(chunk, collapse = "") else ""
504
+ data <- paste0(k$io[[field]], incoming)
505
+ nl <- gregexpr("\n", data, fixed = TRUE)[[1L]]
506
+ if (length(nl) == 1L && nl[[1L]] < 0L) {
507
+ if (flush && nzchar(data)) { k$io[[field]] <- ""; return(data) }
508
+ k$io[[field]] <- data
509
+ return(character())
510
+ }
511
+ last <- nl[[length(nl)]]
512
+ # `last = nchar(data)` is NOT redundant. substring()'s default last is
513
+ # 1000000L, so a two-argument substring() SILENTLY TRUNCATES at exactly one
514
+ # million characters — see the note on the payload slice below, which this
515
+ # is the other half of. Here it would drop the tail of any partial line
516
+ # longer than 1 MB still waiting for its newline.
517
+ complete <- if (last > 1L) substring(data, 1L, last - 1L) else ""
518
+ k$io[[field]] <- if (last < nchar(data))
519
+ substring(data, last + 1L, nchar(data)) else ""
520
+ # Appending a marker preserves a final empty line, which base strsplit()
521
+ # would otherwise discard (cat("\n") is real stdout).
522
+ lines <- strsplit(paste0(complete, "\001"), "\n", fixed = TRUE)[[1L]]
523
+ lines[[length(lines)]] <- sub("\001$", "", lines[[length(lines)]])
524
+ sub("\r$", "", lines)
525
+ }
526
+ # DRAIN the pipe, do not sip from it. read_output() returns whatever one
527
+ # read yields — around 64 KB — and this function is called once per event
528
+ # loop tick, so a big frame arrived at one chunk per tick. Measured: a
529
+ # 2.3 MB plot payload needs ~370 reads, which at the loop's cadence took
530
+ # SIXTEEN SECONDS to deliver something R had drawn in 0.27 s. The dpi was
531
+ # never the cost; the number of ticks was, and it scales with payload, so
532
+ # every large plot, every wide data frame and every long print paid it.
533
+ # ... and yet BOUNDED. An unbounded drain has the opposite failure: a worker
534
+ # producing faster than this loop consumes (`repeat cat("x\n")`) keeps the
535
+ # pipe permanently non-empty, `kernel_poll` never returns, httpuv::service()
536
+ # is never reached — and the Stop the user is pressing can never be
537
+ # delivered. Capping the bytes taken per poll keeps both properties: a big
538
+ # frame still crosses in a couple of ticks instead of hundreds, and the
539
+ # event loop always gets its turn, so interrupts stay deliverable.
540
+ drain <- function(read, max_bytes = 4e6) {
541
+ parts <- character(0)
542
+ total <- 0
543
+ repeat {
544
+ piece <- tryCatch(read(), error = function(e) "")
545
+ if (!length(piece) || !nzchar(piece)) break
546
+ parts[[length(parts) + 1L]] <- piece
547
+ total <- total + nchar(piece, type = "bytes")
548
+ if (total >= max_bytes) break
549
+ }
550
+ if (length(parts)) paste0(parts, collapse = "") else ""
551
+ }
552
+ out <- take_lines("out", drain(function() k$proc$read_output()), !k$proc$is_alive())
553
+ err <- take_lines("err", drain(function() k$proc$read_error()), !k$proc$is_alive())
554
+
555
+ # ── echo scrubbing (interactive workers only) ─────────────────────────────
556
+ # Under processx pipes R does not echo console input back (measured — the
557
+ # echo seen under a shell pipe comes with a controlling tty, which the
558
+ # shipped spawn never has). This scrub is the belt in case some platform's
559
+ # console does: a line carrying the command tag is our own NDJSON coming
560
+ # back, never user output. Found ANYWHERE in the line for the same reason
561
+ # the sentinel is: a cell ending in cat("x") with no newline leaves the
562
+ # cursor mid-line and an echo would land glued to that text. Anything
563
+ # before the tag is real user output and is kept.
564
+ if (identical(k$mode, "interactive") && length(out)) {
565
+ tag <- paste0("#", k$cmdtag)
566
+ scrubbed <- lapply(out, function(line) {
567
+ at <- regexpr(tag, line, fixed = TRUE)
568
+ if (at < 1L) return(line)
569
+ prefix <- substring(line, 1L, at - 1L)
570
+ if (nzchar(prefix)) prefix else NULL
571
+ })
572
+ out <- as.character(unlist(scrubbed))
573
+ }
574
+
575
+ # The sentinel is found ANYWHERE in the line, not only at its start.
576
+ #
577
+ # The worker deliberately does not capture output (sink() would kill
578
+ # streaming), so user code controls where the cursor is when the next
579
+ # control frame is written. A cell ending in `cat("done")` — no trailing
580
+ # newline — leaves the cursor mid-line, and the frame lands glued to that
581
+ # text: `donesentinel{"type":"done",...}`. startsWith() then missed it, the
582
+ # frame was reported as ordinary stdout, and the cell never finished: no
583
+ # `done` ever arrived and the run hung until the user pressed Stop.
584
+ #
585
+ # Splitting at the sentinel instead recovers both halves — the text before
586
+ # it is the user's output, the rest is the frame. This does not weaken the
587
+ # forgery guarantee: that rests on the sentinel being 24 unguessable
588
+ # characters, not on its position in the line.
589
+ # Consecutive plain lines COALESCE into one event. One frame per line made
590
+ # text volume the supervisor's unit of work: a chunk printing 100k lines
591
+ # meant 100k JSON encodes and 100k socket sends in a single-threaded event
592
+ # loop — seconds of stall during which heartbeats and Stop sat unserved.
593
+ # The client accumulates stdout text and joins on "\n", so a multi-line
594
+ # event is byte-identical to the same lines delivered one at a time; only
595
+ # lines carrying a control frame still need individual treatment.
596
+ has_sentinel <- if (length(out)) grepl(k$sentinel, out, fixed = TRUE) else logical(0)
597
+ from_stdout <- if (!length(out)) list() else {
598
+ runs <- rle(has_sentinel)
599
+ ends <- cumsum(runs$lengths)
600
+ starts <- ends - runs$lengths + 1L
601
+ unlist(lapply(seq_along(runs$values), function(r) {
602
+ lines <- out[starts[[r]]:ends[[r]]]
603
+ if (!runs$values[[r]]) {
604
+ return(list(list(type = "stdout", text = paste(lines, collapse = "\n"))))
605
+ }
606
+ unlist(lapply(lines, function(line) parse_control_line(k, line)),
607
+ recursive = FALSE)
608
+ }), recursive = FALSE)
609
+ }
610
+ from_stderr <- if (length(err) && !any(grepl(k$sentinel, err, fixed = TRUE))) {
611
+ list(list(type = "stderr", text = paste(err, collapse = "\n")))
612
+ } else if (length(err)) {
613
+ # Notebook evaluation captures stdout with evaluate. Its private protocol
614
+ # frames therefore use stderr for the duration of that command, through
615
+ # and including done. The same unguessable sentinel authenticates either
616
+ # pipe; ordinary stderr remains stderr, never a control frame.
617
+ unlist(lapply(err, function(line) {
618
+ if (grepl(k$sentinel, line, fixed = TRUE)) parse_control_line(k, line)
619
+ else list(list(type = "stderr", text = line))
620
+ }), recursive = FALSE)
621
+ } else list()
622
+
623
+ c(from_stdout, from_stderr)
624
+ }
625
+
626
+ #' One stdout line KNOWN to carry the sentinel: split it into the user text
627
+ #' before the frame and the frame itself.
628
+ parse_control_line <- function(k, line) {
629
+ at <- regexpr(k$sentinel, line, fixed = TRUE)
630
+ if (at < 1L) return(list(list(type = "stdout", text = line)))
631
+ # `nchar(line)` is NOT redundant: substring()'s default `last` is
632
+ # 1000000L, so the two-argument form silently truncates at exactly one
633
+ # million characters. Every control frame bigger than 1 MB — which means
634
+ # EVERY PLOT above roughly 750 KB of PNG — came out chopped mid-string,
635
+ # failed to parse, and fell through to the branch below that reports the
636
+ # line as ordinary stdout. The plot vanished with no error anywhere: the
637
+ # cell finished "ok", the sentinel and the raw JSON were printed into the
638
+ # output, and the figure simply never appeared. Measured: a 0.25 MB frame
639
+ # arrived, a 0.75 MB frame did not.
640
+ payload <- substring(line, at + nchar(k$sentinel), nchar(line))
641
+ parsed <- tryCatch(fromJSON(payload), error = function(e) NULL)
642
+ if (is.null(parsed)) return(list(list(type = "stdout", text = line)))
643
+ # A control frame must be a JSON OBJECT. Valid JSON that is not one — an
644
+ # array, a bare number, a string — parses CLEANLY into an atomic vector or
645
+ # a data.frame, and the supervisor's first act on a frame is `e$type`,
646
+ # which then raises "$ operator is invalid for atomic vectors". The pumps
647
+ # in serve.R's event loop route that condition out of `repeat`, so the
648
+ # process exits and its shutdown block (chat kill, job kill, runtime-record
649
+ # unlink) never runs. The sentinel is deliberately left in the interactive
650
+ # worker's environment, so a chunk that prints one reaches this line with
651
+ # text of its own choosing. Not an object means not a frame, which is the
652
+ # same answer a parse failure already gets, for the same reason.
653
+ if (!is.list(parsed) || is.data.frame(parsed) || is.null(names(parsed))) {
654
+ return(list(list(type = "stdout", text = line)))
655
+ }
656
+ # The EXACT bytes ride along with the parsed frame, because the supervisor
657
+ # relays this to a browser and re-encoding it there is lossy in three ways
658
+ # at once: jsonlite's default `digits = 4` rounded every number the worker
659
+ # sent (1.2e-5 arrived as 0, and exports wrote that zero), the default
660
+ # `null` rendering turned an absent field into `{}` rather than null, and
661
+ # the simplification above collapses `bins: [500]` back to `bins: 500` —
662
+ # undoing the very I() wrapping worker.R applies to prevent it.
663
+ #
664
+ # The parse stays: routing reads $type and $id, and a faithful re-encode
665
+ # from a nested list measures 225 ms/frame against 4 ms, far too slow for
666
+ # a single-threaded event loop. So the supervisor routes on the parse and
667
+ # forwards the ORIGINAL text. See relay_frame() in serve.R.
668
+ attr(parsed, "raw") <- payload
669
+ prefix <- substring(line, 1L, at - 1L)
670
+ if (nzchar(prefix)) list(list(type = "stdout", text = prefix), parsed) else list(parsed)
671
+ }