beatrina 0.8.6 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,6 +37,7 @@ import { spawn, execFileSync } from "node:child_process";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { PAGE_ONLY_CLASSES, secureToken } from "../server.mjs";
39
39
  import { stateDir } from "../user-dirs.mjs";
40
+ import { encodeSessionDocuments, keepTokenOk } from "../session-keep.mjs";
40
41
 
41
42
  export const SESSION_OPS = Object.freeze(["session_list", "r_versions", "session-new"]);
42
43
  export const HANDOFF_OPS = Object.freeze(["session_upgrade", "session_restart"]);
@@ -268,7 +269,7 @@ export function createPlane({ root, env, audit }) {
268
269
  return script ? { cmd: process.execPath, args: [script] } : { cmd: process.execPath, args: [] };
269
270
  };
270
271
 
271
- const handoffBegin = (ctx, mode = "upgrade", force = false) => {
272
+ const handoffBegin = (ctx, mode = "upgrade", force = false, resume = null) => {
272
273
  mode = mode === "restart" ? "restart" : "upgrade";
273
274
  force = force === true && mode === "restart";
274
275
  const auditName = mode === "restart" ? "session-restart" : "session-upgrade";
@@ -281,13 +282,13 @@ export function createPlane({ root, env, audit }) {
281
282
  const cap = secureToken(32);
282
283
  const page = ctx.notebookFileUrl(ctx.notebookPage(installed), ctx.port(), cap);
283
284
  const started = ctx.startedSeconds;
284
- handoff = { to: installed, cap, page, mode, force };
285
+ handoff = { to: installed, cap, page, mode, force, resume: keepTokenOk(resume) ? resume : null };
285
286
  audit(auditName, { from: ctx.kernelBuild, to: installed, force });
286
287
  // Every page learns where its successor's notebook is — over the gated
287
288
  // socket only; the capability never appears in /health. `started` is this
288
289
  // kernel's boot time: a page recognises its successor by a LATER one, the
289
290
  // only test that also works when the build does not change.
290
- const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force });
291
+ const told = ctx.enc({ type: "session-upgrade", from: ctx.kernelBuild, to: installed, page, started, mode, force, keep: handoff.resume != null });
291
292
  ctx.pageRecs().forEach((r) => { try { r.ws.send(told); } catch { /* gone */ } });
292
293
  const spawned = handoffSpawn(ctx);
293
294
  if (!spawned) return { ok: false, error: "The installed CarmaR could not be started.", reason: "spawn" };
@@ -314,7 +315,10 @@ export function createPlane({ root, env, audit }) {
314
315
  // polling a dead one; strict means it refuses and its log says why.
315
316
  CARMAR_PORT_STRICT: "1",
316
317
  CARMAR_FILE_LAUNCH_CAP: h.cap, CARMAR_HANDOFF_FROM: ctx.kernelBuild,
317
- CARMAR_SESSION_TITLE: record.title || "" };
318
+ CARMAR_SESSION_TITLE: record.title || "",
319
+ CARMAR_SESSION_DOCUMENTS: encodeSessionDocuments(ctx.sessionDocuments?.() || []),
320
+ // Only a token travels, never a path; the successor re-checks it.
321
+ CARMAR_RESUME_TOKEN: h.resume || "" };
318
322
  if (record.listen) childEnv.CARMAR_LISTEN = "1"; else delete childEnv.CARMAR_LISTEN;
319
323
  let out = "ignore";
320
324
  try { out = fs.openSync(path.join(dir, `kernel-handoff-${port}.log`), "a"); } catch { out = "ignore"; }
@@ -375,7 +379,7 @@ export function createPlane({ root, env, audit }) {
375
379
  return reply({ error: "Only the local notebook page may ask this." });
376
380
  }
377
381
  if (HANDOFF_OPS.includes(cmd.type)) {
378
- return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true));
382
+ return reply(handoffBegin(ctx, cmd.type === "session_restart" ? "restart" : "upgrade", cmd.force === true, keepTokenOk(cmd.resume) ? cmd.resume : null));
379
383
  }
380
384
  if (cmd.type === "session_list") {
381
385
  const own = ctx.port();
package/host/server.mjs CHANGED
@@ -30,11 +30,16 @@ import http from "node:http";
30
30
  import path from "node:path";
31
31
  import { attachWebSocket } from "./ws.mjs";
32
32
  import { rookOf, proxyUser } from "./deployment.mjs";
33
+ import { cleanSessionDocuments } from "./session-keep.mjs";
33
34
 
34
35
  export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
35
36
  "package_action", "package_help", "project_status", "project_action", "help", "wd",
36
37
  "parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
37
- "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"]);
38
+ "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks",
39
+ // "Restart, keep variables", step one (spike/workspace-keep.R). Page-only in both senses: AGENT_REFUSED
40
+ // below and the class gate in handleFrame. Step two, `resume`, is deliberately NOT here — only the
41
+ // host asks it, after a restart it performed (WorkerPlane.startResume).
42
+ "suspend"]);
38
43
  export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
39
44
  "input_reply", "debug_cmd"]);
40
45
  /** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
@@ -48,12 +53,16 @@ export const NOT_YET = Object.freeze({
48
53
  "open-request": "the open-file door", "page-title": "", "session-new": "session management",
49
54
  cite_styles: "citations",
50
55
  });
51
- export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"]);
56
+ export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action",
57
+ // A copy of every object in the session, written to disk.
58
+ "suspend", "suspend_cancel"]);
52
59
  /** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
53
60
  export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
54
61
  export const AGENT_WHY = Object.freeze({
55
62
  input_reply: "Agents cannot answer a prompt on the user's behalf.",
56
63
  project_action: "Agents cannot install or restore project packages.",
64
+ suspend: "Agents cannot keep the user's workspace.",
65
+ suspend_cancel: "Agents cannot keep the user's workspace.",
57
66
  });
58
67
  export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
59
68
  export const FILE_ORIGIN = "null";
@@ -111,9 +120,13 @@ export function createHostServer(opts) {
111
120
  const hostsOk = deployment.hosts;
112
121
  const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
113
122
  const approvals = new Set(); // exact origins the user approved this session
114
- const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false };
123
+ const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false,
124
+ // The documents open in this session, as the pages last reported them (host/session-keep.mjs).
125
+ sessionDocuments: Array.isArray(opts.sessionDocuments) ? opts.sessionDocuments : [] };
115
126
  plane.sockets = () => sockets;
116
127
  plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
128
+ // A restore's report is about every object in the session: pages only, never an agent or a native client.
129
+ plane.on("broadcast-pages", (payload) => sockets.filter((r) => PAGE_ONLY_CLASSES.includes(r.class) && r.role === "page").forEach((r) => r.ws.send(payload)));
117
130
 
118
131
  // The session stamp: ONE string, rendered once, that the page's meta and
119
132
  // /health's `started` share so they can never differ in their digits.
@@ -187,6 +200,7 @@ export function createHostServer(opts) {
187
200
  const ctx = {
188
201
  deployment, plane, audit, sockets, approvals, state, opts,
189
202
  enc, scalarChr, pageRecs, socketClass,
203
+ sessionDocuments: () => state.sessionDocuments,
190
204
  port: () => (server.address() ? server.address().port : deployment.port),
191
205
  respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
192
206
  reject: (res, reason, detail) => reject(res, reason, detail),
@@ -285,6 +299,11 @@ export function createHostServer(opts) {
285
299
  if (plane.hello) ws.send(plane.hello);
286
300
  if (plane.notice) ws.send(plane.notice);
287
301
  if (typeof plane.replay === "function") plane.replay(ws);
302
+ // …and a restore that finished before this page arrived (a page following a handoff).
303
+ if (PAGE_ONLY_CLASSES.includes(rec.class) && typeof plane.freshResumeReport === "function") {
304
+ const report = plane.freshResumeReport();
305
+ if (report) ws.send(report);
306
+ }
288
307
  for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
289
308
  ws.onMessage((message) => {
290
309
  try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
@@ -346,6 +365,24 @@ export function createHostServer(opts) {
346
365
  rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
347
366
  return undefined;
348
367
  }
368
+ // The documents this session has open. Page-only in BOTH senses: the list is file paths, and a page
369
+ // that could not open a terminal must not be able to rewrite what the session reopens either.
370
+ if (type === "page-documents") {
371
+ if (rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class)) {
372
+ state.sessionDocuments = cleanSessionDocuments(cmd.documents);
373
+ opts.onSessionDocuments?.(state.sessionDocuments);
374
+ }
375
+ return undefined;
376
+ }
377
+ // A page asks what this session had open, to reopen it. `others` counts the OTHER pages attached: a
378
+ // page joining a session another window shows must not open the same documents a second time.
379
+ if (type === "session-documents") {
380
+ if (!scalarChr(cmd.id)) return undefined;
381
+ const allowed = rec.role === "page" && PAGE_ONLY_CLASSES.includes(rec.class);
382
+ const others = pageRecs().filter((r) => r !== rec).length;
383
+ rec.ws.send(enc({ type, id: cmd.id, documents: allowed ? state.sessionDocuments : [], others, refused: !allowed }));
384
+ return undefined;
385
+ }
349
386
  if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
350
387
  audit("mcp-refused", { reason: `agent asked for ${type}` });
351
388
  // serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
@@ -363,14 +400,22 @@ export function createHostServer(opts) {
363
400
  audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
364
401
  return undefined;
365
402
  }
403
+ // "Restart, keep variables": the save is a copy of the whole session, so it is page-only in BOTH
404
+ // senses, and its cancel interrupts only a save THIS page asked for.
405
+ if ((type === "suspend" || type === "suspend_cancel") && !PAGE_ONLY_CLASSES.includes(rec.class)) {
406
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: "Only the local notebook page may keep the workspace." }));
407
+ audit("suspend-refused", { reason: "class", class: rec.class || "unknown" });
408
+ return undefined;
409
+ }
366
410
  switch (type) {
411
+ case "suspend_cancel": if (typeof plane.cancelSuspend === "function") plane.cancelSuspend(rec); return undefined;
367
412
  case "exec": return plane.exec(cmd, rec);
368
413
  case "interrupt": return plane.interrupt(cmd, rec);
369
414
  // Both carry an OPTIONAL engine. `restart` with none is the primary
370
415
  // engine's — which is what "Restart R" in the page means and what every
371
416
  // client built before there was a second engine sends (§6.5).
372
417
  case "force_stop": return plane.forceStop(rec, cmd);
373
- case "restart": Promise.resolve(plane.restart(cmd.engine)).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
418
+ case "restart": Promise.resolve(plane.restart(cmd.engine, { resume: cmd.resume })).catch((e) => audit("restart-failed", { detail: e.message })); return undefined;
374
419
  case "runstate": return plane.runstate(cmd, rec);
375
420
  case "runs": return plane.runs(cmd, rec);
376
421
  case "adopt": return plane.adopt(cmd, rec);
@@ -0,0 +1,70 @@
1
+ // session-keep.mjs — the pure rules behind two CarmaR 0.8.5 features, as the host answers them.
2
+ //
3
+ // · "Restart, keep variables": the worker saves the session (`suspend`, spike/workspace-keep.R) and
4
+ // answers a TOKEN; the host never reads a kept workspace, it only checks that a token is one the
5
+ // worker could have minted and carries it to the next worker (`resume`). A path is never a token.
6
+ // · "A session keeps its documents": the page reports what it has open (`page-documents`), the host
7
+ // keeps the list, labels the runtime record with it, and hands it back (`session-documents`).
8
+ //
9
+ // Transcribed rule for rule from spike/workspace-keep.R (keep_token_ok) and spike/session-documents.R
10
+ // (session_documents_clean/label/encode/decode), which CarmaR's R supervisor sources; pinned against
11
+ // the same cases by test/session-keep.test.mjs.
12
+
13
+ import path from "node:path";
14
+
15
+ export const KEEP_TOKEN_RE = /^[0-9]{14}-[0-9a-f]{16}$/;
16
+ export const SESSION_DOCUMENTS_MAX = 64;
17
+ export const SESSION_DOCUMENT_FORMATS = Object.freeze(["qmd", "Rmd", "md", "carmd"]);
18
+ /** How long a restore's report is replayed to a page that connects late (serve.R: 120 s). */
19
+ export const RESUME_REPORT_MS = 120000;
20
+
21
+ /** Is `x` a token the worker could have minted? */
22
+ export const keepTokenOk = (x) => typeof x === "string" && KEEP_TOKEN_RE.test(x);
23
+
24
+ // eslint-disable-next-line no-control-regex
25
+ const CONTROL = /[\x00-\x1f\x7f]/g;
26
+ const textOf = (value, cap) => {
27
+ if (typeof value !== "string") return null;
28
+ const v = value.replace(CONTROL, " ");
29
+ return v.length > cap ? null : v;
30
+ };
31
+
32
+ /** The documents a page reported, cleaned: absolute paths, bounded names, known formats, no duplicates. */
33
+ export function cleanSessionDocuments(documents) {
34
+ if (!Array.isArray(documents) || !documents.length) return [];
35
+ const rows = [];
36
+ const seen = new Set();
37
+ for (const row of documents) {
38
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
39
+ const p = textOf(row.path ?? "", 4096);
40
+ const name = textOf(row.name ?? "", 200);
41
+ let format = textOf(row.format ?? "", 16);
42
+ if (p == null || name == null || format == null) continue;
43
+ // Reopened through the file ops, which resolve it themselves; here it need only be absolute.
44
+ if (p && !/^(\/|[A-Za-z]:[/\\])/.test(p)) continue;
45
+ if (!p && !name.trim()) continue;
46
+ if (!SESSION_DOCUMENT_FORMATS.includes(format)) format = "qmd";
47
+ if (p) { if (seen.has(p)) continue; seen.add(p); }
48
+ rows.push({ path: p, name: name.trim(), format, active: row.active === true });
49
+ if (rows.length >= SESSION_DOCUMENTS_MAX) break;
50
+ }
51
+ return rows;
52
+ }
53
+
54
+ /** "SezerProfiles.qmd + 1 more": what the menu helper shows for a session. */
55
+ export function sessionDocumentsLabel(documents) {
56
+ if (!Array.isArray(documents) || !documents.length) return "";
57
+ const lead = documents.find((d) => d.active === true) || documents[0];
58
+ let name = lead.path ? path.basename(lead.path.replace(/\\/g, "/")) : lead.name;
59
+ if (!lead.path) name = `${name} (unsaved)`;
60
+ const rest = documents.length - 1;
61
+ const label = rest > 0 ? `${name} + ${rest} more` : name;
62
+ return label.replace(CONTROL, " ").replace(/["\\]/g, "'").slice(0, 120).trim();
63
+ }
64
+
65
+ export const encodeSessionDocuments = (documents) => (Array.isArray(documents) && documents.length ? JSON.stringify(documents) : "");
66
+
67
+ export function decodeSessionDocuments(text) {
68
+ if (typeof text !== "string" || !text) return [];
69
+ try { return cleanSessionDocuments(JSON.parse(text)); } catch { return []; }
70
+ }
@@ -35,6 +35,7 @@
35
35
  // an explicit `restart` is the one door out of it.
36
36
 
37
37
  import { EventEmitter } from "node:events";
38
+ import { RESUME_REPORT_MS, keepTokenOk } from "./session-keep.mjs";
38
39
 
39
40
  export const WORKER_DEADLINE_FAST = 120;
40
41
  export const WORKER_DEADLINE_SLOW = 600;
@@ -119,6 +120,10 @@ export class WorkerPlane extends EventEmitter {
119
120
  this.terminal = null; // {wireId, route, frame, timer}
120
121
  this.running = new Set(); // exec wire ids in flight (the busy guard's evidence)
121
122
  this.hello = null; // the decorated ready payload, replayed to late pages
123
+ // "Restart, keep variables" (host/session-keep.mjs): a token for the NEXT ready worker, consumed
124
+ // once; and the restore's report, replayed to a page that connects within RESUME_REPORT_MS.
125
+ this.resumePending = null;
126
+ this.resumeReport = null;
122
127
  this.notice = null; // a kept give-up notice
123
128
  this.readyFrame = null;
124
129
  this.workerWd = process.cwd();
@@ -314,8 +319,8 @@ export class WorkerPlane extends EventEmitter {
314
319
  if (this.alive) this.engine.kill();
315
320
  }
316
321
 
317
- async restart() {
318
- this.audit("restart", {});
322
+ async restart({ resume } = {}) {
323
+ this.audit("restart", { keep: keepTokenOk(resume) });
319
324
  this.failRoutes(`${this.label} was restarted — this request was abandoned.`);
320
325
  this.expectedExit = true;
321
326
  if (this.engine) await this.engine.stop(1000);
@@ -325,9 +330,48 @@ export class WorkerPlane extends EventEmitter {
325
330
  this.restartAttempts = 0;
326
331
  this.gaveUp = false;
327
332
  this.notice = null;
333
+ this.resumePending = keepTokenOk(resume) ? resume : null;
328
334
  await this.start();
329
335
  }
330
336
 
337
+ /** A token for the next ready worker (a handoff successor's CARMAR_RESUME_TOKEN). */
338
+ setResumePending(token) { this.resumePending = keepTokenOk(token) ? token : null; }
339
+
340
+ /**
341
+ * Ask the fresh worker to restore a kept workspace, and tell every page what came back. Queued like
342
+ * any command, so a chunk run pressed meanwhile waits for the restore rather than racing it.
343
+ */
344
+ startResume() {
345
+ const token = this.resumePending;
346
+ this.resumePending = null;
347
+ if (!keepTokenOk(token)) return;
348
+ this.audit("resume-start", {});
349
+ this.internal({ type: "resume", token }, (frame) => {
350
+ if (scalarChr(frame.cwd)) this.workerWd = frame.cwd;
351
+ const report = { type: "workspace_restored", restored: frame.restored ?? 0, failed: frame.failed ?? [],
352
+ skipped: frame.skipped ?? [], wd_missing: frame.wd_missing ?? null, error: frame.error ?? null };
353
+ const payload = enc(report);
354
+ this.resumeReport = { at: Date.now(), payload };
355
+ this.audit("resume-done", { restored: report.restored, failed: Array.isArray(report.failed) ? report.failed.length : 0, error: report.error != null });
356
+ this.emit("broadcast-pages", payload);
357
+ });
358
+ }
359
+
360
+ /** The report of a restore that finished in the last RESUME_REPORT_MS, or null. */
361
+ freshResumeReport(now = Date.now()) {
362
+ return this.resumeReport && now - this.resumeReport.at < RESUME_REPORT_MS ? this.resumeReport.payload : null;
363
+ }
364
+
365
+ /** Cancel a `suspend` — only one THIS page asked for, never someone else's run. */
366
+ cancelSuspend(rec) {
367
+ const route = this.active != null ? this.routes.get(this.active) : null;
368
+ if (this.activeType !== "suspend" || !route || route.rec !== rec) return false;
369
+ this.audit("suspend-cancel", {});
370
+ const wireId = this.active;
371
+ this.engine.interrupt(() => wireId === this.active);
372
+ return true;
373
+ }
374
+
331
375
  resetSession() {
332
376
  this.running.clear();
333
377
  this.routes.clear();
@@ -535,6 +579,7 @@ export class WorkerPlane extends EventEmitter {
535
579
  this.hello = payload;
536
580
  this.emit("broadcast", payload);
537
581
  this.emit("ready", decorated);
582
+ if (this.resumePending) this.startResume();
538
583
  return;
539
584
  }
540
585
  this.emit("broadcast", relayFrame(e));
@@ -1 +1 @@
1
- 0.8.6
1
+ 0.8.7
package/kernel/kernel.R CHANGED
@@ -246,16 +246,18 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
246
246
  clean_env[["CARMAR_SENTINEL"]] <- sentinel
247
247
  clean_env[["CARMAR_CMD_TAG"]] <- cmdtag
248
248
  clean_env[["CARMAR_WORKER_MODE"]] <- "interactive"
249
+ # readline's screen width: at 80 a long command line echoes as "<" + tail with
250
+ # no cmdtag (Linux; host/engine-r.mjs has the measurement). The boot line gives
251
+ # user code back its own COLUMNS.
252
+ clean_env[["CARMAR_USER_COLUMNS"]] <- Sys.getenv("COLUMNS", "")
253
+ clean_env[["COLUMNS"]] <- "100000"
249
254
  clean_env[["CARMAR_WORKER_DIR"]] <- dirname(normalizePath(worker_path, mustWork = FALSE))
250
255
  }
251
256
  spawn_bin <- if (identical(mode, "interactive")) r_binary else rscript
252
257
  spawn_args <- if (identical(mode, "interactive")) {
253
258
  # --no-echo suppresses the "> " prompt; the input ECHO it does not suppress
254
259
  # 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")
260
+ c("--interactive", "--no-echo", "--no-save", "--no-restore", "--no-site-file")
259
261
  } else {
260
262
  # --vanilla, NOT --no-init-file: the user's .Rprofile/.Renviron are part of
261
263
  # their R (library paths, repos, options). Only history and saved workspaces
@@ -292,7 +294,8 @@ kernel_start <- function(worker_path, sentinel = NULL, rscript = detect_rscript(
292
294
  if (!file.exists(real_worker)) real_worker <- worker_path
293
295
  worker_boot <- sprintf('sys.source("%s", envir = globalenv(), keep.source = FALSE)',
294
296
  encodeString(normalizePath(real_worker)))
295
- boot <- paste(Filter(nzchar, c(macos_background_boot(worker_path), worker_boot)),
297
+ width_boot <- 'local({ cols <- Sys.getenv("CARMAR_USER_COLUMNS"); Sys.unsetenv("CARMAR_USER_COLUMNS"); if (nzchar(cols)) Sys.setenv(COLUMNS = cols) else Sys.unsetenv("COLUMNS") })'
298
+ boot <- paste(Filter(nzchar, c(width_boot, macos_background_boot(worker_path), worker_boot)),
296
299
  collapse = ";")
297
300
  kernel_console(k, boot)
298
301
  }