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,441 @@
1
+ // server.mjs — Beatrix's supervisor: HTTP + WebSocket on one loopback port,
2
+ // the gates, and the frame dispatcher. NEVER evaluates user code.
3
+ //
4
+ // This is spike/serve.R's shape with the language-neutral half kept and the
5
+ // evaluating half behind an engine adapter (host/worker-plane.mjs). Serving the
6
+ // page from the same origin as the socket is deliberate: no CORS, no Private
7
+ // Network Access preflight, a secure context.
8
+ //
9
+ // Who may open a socket is decided by `Origin`, and there are exactly three
10
+ // answers: the origin this host served (allowed), an ABSENT Origin (allowed on
11
+ // a private loopback — a native same-user process could run R itself), and
12
+ // anything else only if the user approved that exact string this session.
13
+ // A file page (`Origin: null`) may also present the launch capability from the
14
+ // URL this host printed, which answers one question only: "is this the file I
15
+ // told you to open, or a file that went looking?"
16
+ //
17
+ // The allow-lists are security decisions (CLAUDE.md, serve.R): an op absent
18
+ // from every list is unreachable from the browser. This milestone routes the
19
+ // evaluating vocabulary and the session controls; every supervisor-answered
20
+ // family serve.R has (files, journal, AI store, analyzer, settings, jobs,
21
+ // terminal, citations, plugins) is REFUSED WITH A REASON rather than dropped,
22
+ // because a silent drop reads as a hang on the other end. Those families are
23
+ // the later work packages and are not advertised in the ready frame's
24
+ // `commands`, so a page decides "this kernel predates X" by name (the rule
25
+ // lib/kernel-capability.js relies on) instead of waiting on nothing.
26
+
27
+ import crypto from "node:crypto";
28
+ import fs from "node:fs";
29
+ import http from "node:http";
30
+ import path from "node:path";
31
+ import { attachWebSocket } from "./ws.mjs";
32
+ import { rookOf, proxyUser } from "./deployment.mjs";
33
+
34
+ export const FORWARDED = Object.freeze(["env", "obj", "struct", "view", "colstats", "rm", "packages", "doctor",
35
+ "package_action", "package_help", "project_status", "project_action", "help", "wd",
36
+ "parse", "complete", "files", "import", "readfile", "writefile", "writefiles_atomic",
37
+ "hover", "format", "sniff", "mkdir", "renamepath", "deletepath", "copypath", "revealpath", "debug_breaks"]);
38
+ export const SESSION_CONTROLS = Object.freeze(["exec", "interrupt", "force_stop", "restart", "runstate", "runs", "adopt",
39
+ "input_reply", "debug_cmd"]);
40
+ /** Answered by serve.R itself, not yet by this host. Refused by NAME so the page never hangs. */
41
+ export const NOT_YET = Object.freeze({
42
+ files: "the file system", journal: "the document journal", ai: "the AI conversation store",
43
+ settings: "settings", job: "development jobs", term: "the terminal",
44
+ cite: "citations", csl: "citation styles", plugin: "plugins", session: "session management",
45
+ update: "the update check", mcp: "the agent plane", agent: "the agent plane", latex: "the LaTeX preview",
46
+ console_history: "console history", prefs: "the appearance carry", "ai-key": "the AI key",
47
+ "ai-policy": "the AI policy", "ai-audit": "the AI audit", "ai-audit-read": "the AI audit",
48
+ "open-request": "the open-file door", "page-title": "", "session-new": "session management",
49
+ cite_styles: "citations",
50
+ });
51
+ export const AGENT_REFUSED = Object.freeze(["exec", "interrupt", "force_stop", "restart", "debug_cmd", "input_reply", "project_action"]);
52
+ /** Why, in the sentence serve.R uses. A plane supplies its own via `agentReason`. */
53
+ export const AGENT_WHY_DEFAULT = "Agents run code through notebook chunks (chunk_run), not raw exec.";
54
+ export const AGENT_WHY = Object.freeze({
55
+ input_reply: "Agents cannot answer a prompt on the user's behalf.",
56
+ project_action: "Agents cannot install or restore project packages.",
57
+ });
58
+ export const PAGE_ONLY_CLASSES = Object.freeze(["served", "file", "local"]);
59
+ export const FILE_ORIGIN = "null";
60
+ export const LOCAL_ORIGIN_RE = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):[0-9]{1,5}$/;
61
+ export const MAX_FRAME_BYTES = 8e6;
62
+ export const MAX_SOCKETS = 32;
63
+ export const SAFE_HEADERS = Object.freeze({
64
+ "Cache-Control": "no-store",
65
+ "X-Content-Type-Options": "nosniff",
66
+ "X-Frame-Options": "DENY",
67
+ "Referrer-Policy": "no-referrer",
68
+ "Content-Security-Policy": "frame-ancestors 'none'",
69
+ });
70
+ const scalarChr = (x) => typeof x === "string" && x.length > 0;
71
+ const enc = (obj) => JSON.stringify(obj);
72
+
73
+ /** Bytes no caller can predict — the OS CSPRNG, never Math.random. */
74
+ export function secureToken(n = 32) {
75
+ const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
76
+ const bytes = crypto.randomBytes(n);
77
+ let out = "";
78
+ for (const b of bytes) out += alphabet[b % alphabet.length];
79
+ return out;
80
+ }
81
+
82
+ /** Stamp the served HTML with this session's identity, once per load. */
83
+ export function stampSession(html, stamp) {
84
+ const meta = `<meta name="carmar-session" content="${stamp}">`;
85
+ let m = html.match(/<head\b[^>]*>/);
86
+ if (!m) m = html.match(/<html\b[^>]*>/);
87
+ if (!m) return meta + html;
88
+ const cut = m.index + m[0].length;
89
+ return html.slice(0, cut) + meta + html.slice(cut);
90
+ }
91
+
92
+ /**
93
+ * @param {Object} opts
94
+ * @param {ReturnType<import("./deployment.mjs").carmarDeployment>} opts.deployment
95
+ * @param {import("./engine-pool.mjs").EnginePool} opts.plane the engine
96
+ * router; it presents one WorkerPlane's surface, so everything here reads as
97
+ * it did when there was one engine (WP4, docs/wp/wp4-python.md)
98
+ * @param {() => string} opts.pagePath the built notebook for this build ("" = none)
99
+ * @param {string} opts.kernelBuild
100
+ * @param {() => string} opts.installedBuild
101
+ * @param {number} opts.protocol
102
+ * @param {number} opts.bootAt ms since epoch
103
+ * @param {string} opts.fileLaunchCap
104
+ * @param {(event: string, fields?: object) => void} opts.audit
105
+ * @param {() => void} opts.onShutdown
106
+ * @param {string} opts.here the directory holding the fallback spike page
107
+ */
108
+ export function createHostServer(opts) {
109
+ const { deployment, plane, audit, onShutdown, planes = [] } = opts;
110
+ const originsOk = deployment.origins;
111
+ const hostsOk = deployment.hosts;
112
+ const sockets = []; // records: {ws, role, name, class, user, lastSeen, beats}
113
+ const approvals = new Set(); // exact origins the user approved this session
114
+ const state = { everConnected: false, heldAt: null, pendingOpen: opts.pendingOpen || null, quit: false };
115
+ plane.sockets = () => sockets;
116
+ plane.on("broadcast", (payload) => sockets.forEach((r) => r.ws.send(payload)));
117
+
118
+ // The session stamp: ONE string, rendered once, that the page's meta and
119
+ // /health's `started` share so they can never differ in their digits.
120
+ const startedSeconds = Number((opts.bootAt / 1000).toFixed(4));
121
+ const sessionStamp = enc(startedSeconds);
122
+
123
+ const hostOk = (req) => {
124
+ const h = String(req.headers.host || "").toLowerCase();
125
+ return Boolean(h) && hostsOk.includes(h);
126
+ };
127
+ const reject = (res, reason, detail = "") => {
128
+ audit("rejected", { reason, detail });
129
+ res.writeHead(403, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
130
+ res.end(`forbidden: ${reason}`);
131
+ };
132
+ const respond = (res, status, type, body, extra = {}) => {
133
+ res.writeHead(status, { "Content-Type": type, ...SAFE_HEADERS, ...extra });
134
+ res.end(body);
135
+ };
136
+ const queryParam = (req, name) => {
137
+ const u = new URL(req.url, "http://x");
138
+ return u.searchParams.get(name) || "";
139
+ };
140
+
141
+ /** Refuse browser-mediated control requests from any other site. */
142
+ const controlRejection = (req, { allowFile = false, allowApproved = false } = {}) => {
143
+ const origin = req.headers.origin || "";
144
+ const approved = allowApproved && Boolean(origin) && approvals.has(origin);
145
+ if (origin && !originsOk.includes(origin) && !approved && !(allowFile && origin === FILE_ORIGIN)) {
146
+ return { reason: "bad origin", detail: origin };
147
+ }
148
+ if (!origin && !deployment.allow_native) return { reason: "origin required", detail: "(none)" };
149
+ const admitted = (allowFile && origin === FILE_ORIGIN) || approved;
150
+ const site = String(req.headers["sec-fetch-site"] || "").toLowerCase();
151
+ if (!admitted && site === "cross-site") return { reason: "cross-site request", detail: site };
152
+ return null;
153
+ };
154
+
155
+ /** Which of the accepted classes opened this socket? Fails CLOSED. */
156
+ const socketClass = (req) => {
157
+ if (!req) return "unknown";
158
+ const origin = req.headers.origin;
159
+ if (origin == null) return deployment.allow_native ? "native" : "unknown";
160
+ if (originsOk.includes(origin)) return "served";
161
+ if (origin === FILE_ORIGIN) return "file";
162
+ if (LOCAL_ORIGIN_RE.test(origin)) return "local";
163
+ return "published";
164
+ };
165
+
166
+ const pageRecs = () => sockets.filter((r) => r.role === "page");
167
+ // serve.R reports its OWN R.version here, available from the first byte;
168
+ // the host is not R, so before the worker's ready frame it answers with the
169
+ // version the chosen Rscript printed at boot (main.mjs's hint), then the
170
+ // worker's own once it has said it.
171
+ const rVersion = () => {
172
+ const r = plane.readyFrame && plane.readyFrame.r;
173
+ const m = typeof r === "string" ? r.match(/R version (\S+)/) : null;
174
+ if (m) return m[1];
175
+ return typeof opts.rVersionHint === "function" ? (opts.rVersionHint() || "") : "";
176
+ };
177
+
178
+ // ── the plane seam ───────────────────────────────────────────────────────
179
+ // A PLANE is one supervisor-answered family (files, journal, jobs, …) in its
180
+ // own module under host/planes/. It declares the ops it answers (`ops`),
181
+ // the verbs it wants advertised in `ready.commands` (`commands`, only what it
182
+ // serves), optional HTTP routes (`http(req, res, pathname, ctx) → true when
183
+ // handled`), and socket hooks (`onOpen`, `onClose`). Planes are consulted
184
+ // BEFORE the not-yet refusals, so serving a family is registering a plane in
185
+ // host/main.mjs and nothing else; two planes claiming one op is a startup
186
+ // error, not a silent shadow.
187
+ const ctx = {
188
+ deployment, plane, audit, sockets, approvals, state, opts,
189
+ enc, scalarChr, pageRecs, socketClass,
190
+ port: () => (server.address() ? server.address().port : deployment.port),
191
+ respond: (res, status, type, body, extra) => respond(res, status, type, body, extra),
192
+ reject: (res, reason, detail) => reject(res, reason, detail),
193
+ controlRejection: (req, o) => controlRejection(req, o),
194
+ queryParam: (req, name) => queryParam(req, name),
195
+ broadcast: (payload) => sockets.forEach((r) => r.ws.send(payload)),
196
+ installedBuild: opts.installedBuild,
197
+ kernelBuild: opts.kernelBuild,
198
+ notebookPage: (build) => (typeof opts.notebookPage === "function" ? opts.notebookPage(build) : ""),
199
+ notebookFileUrl: (p, port, cap) => (typeof opts.notebookFileUrl === "function" ? opts.notebookFileUrl(p, port, cap) : ""),
200
+ runtimeRecord: () => (typeof opts.runtimeRecord === "function" ? opts.runtimeRecord() : {}),
201
+ shutdown: (reason) => opts.onShutdown?.(reason),
202
+ startedSeconds,
203
+ fileLaunchCap: opts.fileLaunchCap,
204
+ sessionStamp: () => sessionStamp,
205
+ };
206
+ const opOwner = new Map();
207
+ for (const pl of planes) {
208
+ for (const op of pl.ops || []) {
209
+ if (opOwner.has(op)) throw new Error(`Beatrina: op "${op}" is claimed by two planes (${opOwner.get(op).name} and ${pl.name})`);
210
+ opOwner.set(op, pl);
211
+ }
212
+ }
213
+
214
+ const server = http.createServer((req, res) => {
215
+ if (!hostOk(req)) return reject(res, "bad host", req.headers.host || "(none)");
216
+ const pathname = new URL(req.url, "http://x").pathname;
217
+ if (pathname === "/health") {
218
+ if (queryParam(req, "hold") === "1") state.heldAt = Date.now();
219
+ const newerOnNpm = opts.updateAvailable ? opts.updateAvailable() : null;
220
+ // CORS is granted HERE AND ONLY HERE, so a file page probing for its
221
+ // kernel can read the answer. Booleans and versions only.
222
+ return respond(res, 200, "application/json", enc({
223
+ ok: true, worker: plane.alive, pending_open: state.pendingOpen != null,
224
+ r: rVersion(), started: startedSeconds, protocol: opts.protocol,
225
+ kernel_build: opts.kernelBuild, installed_build: opts.installedBuild(),
226
+ pages: pageRecs().length,
227
+ // A newer Beatrina on npm (host/update-record.mjs), only while one is known.
228
+ ...(newerOnNpm ? { update_available: newerOnNpm } : {}),
229
+ // Which build this kernel replaced, for the first 90 s of its life —
230
+ // how a page recognises its successor. Never the page or its capability.
231
+ ...(opts.handoffFrom && Date.now() - opts.bootAt <= 90000 ? { handoff_from: opts.handoffFrom } : {}),
232
+ capabilities: ["published-direct-v1", "published-pairing-v3", "session-restart-v1", ...planes.flatMap((pl) => pl.capabilities || [])],
233
+ // `engines` is what a chunk may be RUN on, so a file page probing for
234
+ // a kernel can tell a two-engine host from a one-engine one before it
235
+ // opens a socket. It names every engine a runtime was found for, not
236
+ // only the ones that have started (WP4; docs/wp/wp4-python.md).
237
+ host: "beatrix",
238
+ // Which product this host is (BEATRIX_APP_ID): a report from the page files under it.
239
+ app: opts.app || "carmar",
240
+ engines: typeof plane.engineNames !== "undefined" ? plane.engineNames : (plane.language ? [plane.language] : []),
241
+ engine_status: typeof plane.engineRows === "function" ? plane.engineRows() : undefined,
242
+ }), { "Access-Control-Allow-Origin": "*" });
243
+ }
244
+ if (pathname === "/shutdown") {
245
+ const blocked = controlRejection(req, { allowFile: true });
246
+ if (blocked) return reject(res, blocked.reason, blocked.detail);
247
+ audit("shutdown", {});
248
+ state.quit = true;
249
+ respond(res, 200, "application/json", enc({ ok: true, stopping: true }));
250
+ setTimeout(() => onShutdown(), 50);
251
+ return undefined;
252
+ }
253
+ for (const pl of planes) {
254
+ if (typeof pl.http === "function" && pl.http(req, res, pathname, ctx) === true) return undefined;
255
+ }
256
+ if (pathname === "/" || pathname === "/spike") {
257
+ const page = pathname === "/spike" ? path.join(opts.here, "index.html") : (opts.pagePath() || path.join(opts.here, "index.html"));
258
+ let html;
259
+ try { html = fs.readFileSync(page, "utf8"); } catch { return respond(res, 404, "text/plain", "no notebook page is built for this kernel"); }
260
+ return respond(res, 200, "text/html", stampSession(html, sessionStamp));
261
+ }
262
+ return respond(res, 404, "text/plain", "not found");
263
+ });
264
+
265
+ attachWebSocket(server, {
266
+ gate: (req) => {
267
+ if (!hostOk(req)) { audit("rejected", { reason: "bad host", detail: req.headers.host || "(none)" }); return { status: 403, body: "forbidden: bad host" }; }
268
+ const origin = req.headers.origin;
269
+ const approved = origin != null && approvals.has(origin);
270
+ const launched = origin === FILE_ORIGIN && new URL(req.url, "http://x").searchParams.get("pair") === opts.fileLaunchCap;
271
+ if (origin == null && !deployment.allow_native) { audit("rejected", { reason: "origin required", detail: "(none)" }); return { status: 403, body: "forbidden: origin required" }; }
272
+ if (origin != null && !originsOk.includes(origin) && !approved && !launched) { audit("rejected", { reason: "bad origin", detail: origin }); return { status: 403, body: "forbidden: bad origin" }; }
273
+ if (sockets.length >= MAX_SOCKETS) { audit("rejected", { reason: "too many connections" }); return { status: 403, body: "forbidden: too many connections" }; }
274
+ return null;
275
+ },
276
+ onOpen: (ws) => {
277
+ const rec = { ws, role: "page", name: "", class: socketClass(ws.request),
278
+ user: proxyUser(rookOf(ws.request), deployment), lastSeen: Date.now(), beats: false };
279
+ sockets.push(rec);
280
+ state.everConnected = true;
281
+ audit("socket-open", { sockets: sockets.length, class: rec.class, user: rec.user });
282
+ // The engine announced itself long before this page connected; replay
283
+ // it — and every other engine that has since started, each under its own
284
+ // `engine-ready` (host/engine-pool.mjs replay()).
285
+ if (plane.hello) ws.send(plane.hello);
286
+ if (plane.notice) ws.send(plane.notice);
287
+ if (typeof plane.replay === "function") plane.replay(ws);
288
+ for (const pl of planes) { try { pl.onOpen?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
289
+ ws.onMessage((message) => {
290
+ try { handleFrame(message, rec); } catch (e) { audit("frame-error", { detail: e.message }); }
291
+ });
292
+ ws.onClose(() => {
293
+ const at = sockets.indexOf(rec);
294
+ if (at >= 0) sockets.splice(at, 1);
295
+ plane.dropSocket(rec);
296
+ for (const pl of planes) { try { pl.onClose?.(rec, ctx); } catch (e) { audit("plane-error", { plane: pl.name, detail: e.message }); } }
297
+ audit("socket-close", { sockets: sockets.length });
298
+ });
299
+ },
300
+ });
301
+
302
+ /** Best-effort refusal for a frame that cannot be forwarded. */
303
+ const refuseFrame = (message, rec, reason) => {
304
+ const head = message.slice(0, 2048);
305
+ const type = (head.match(/"type"\s*:\s*"([A-Za-z_-]{1,32})"/) || [])[1];
306
+ const id = (head.match(/"id"\s*:\s*"([^"]{1,128})"/) || [])[1];
307
+ if (!type || !id) return;
308
+ rec.ws.send(enc(type === "exec"
309
+ ? { type: "done", id, status: "error", message: reason }
310
+ : { type, id, error: reason }));
311
+ };
312
+
313
+ function handleFrame(message, rec) {
314
+ const bytes = Buffer.byteLength(message);
315
+ if (bytes > MAX_FRAME_BYTES) {
316
+ audit("frame-too-large", { bytes });
317
+ return refuseFrame(message, rec, `This request is larger than the kernel accepts (${(bytes / 1e6).toFixed(1)} MB against an 8 MB limit).`);
318
+ }
319
+ let cmd = null;
320
+ try { cmd = JSON.parse(message); } catch { cmd = null; }
321
+ if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || !scalarChr(cmd.type)) return undefined;
322
+ rec.lastSeen = Date.now();
323
+ const type = cmd.type;
324
+
325
+ if (type === "hb") {
326
+ rec.beats = true;
327
+ rec.ws.send(enc({ type: "hb", installed_build: opts.installedBuild() }));
328
+ return undefined;
329
+ }
330
+ if (type === "page-title") {
331
+ // serve.R: ANY page-title from a page retires the listener mark, and a
332
+ // single string — the EMPTY one included, which is how a page clears
333
+ // its title — or an absent one sets it. `scalarChr` refuses "", which
334
+ // is why an emptied title never left the runtime record.
335
+ if (rec.role === "page") {
336
+ const settable = typeof cmd.title === "string" || cmd.title == null;
337
+ opts.onPageTitle?.(settable ? (cmd.title ?? "") : null);
338
+ }
339
+ return undefined;
340
+ }
341
+ if (type === "open-request") {
342
+ if (rec.role !== "page") return undefined;
343
+ const p = state.pendingOpen;
344
+ state.pendingOpen = null;
345
+ if (p) audit("open-file-delivered", {});
346
+ rec.ws.send(enc({ type: "open-request", id: scalarChr(cmd.id) ? cmd.id : "open", path: p || "" }));
347
+ return undefined;
348
+ }
349
+ if (AGENT_REFUSED.includes(type) && rec.role === "mcp") {
350
+ audit("mcp-refused", { reason: `agent asked for ${type}` });
351
+ // serve.R answers IN THE ASKED TYPE with an `error`, never a `done`: the
352
+ // refusal is of the request, not the outcome of a run that never started.
353
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: AGENT_WHY[type] || AGENT_WHY_DEFAULT }));
354
+ return undefined;
355
+ }
356
+ // Installing or restoring a project's packages downloads and compiles code
357
+ // in the worker. It is a FORWARDED op, so no plane stands in front of it:
358
+ // this is serve.R's class gate, restated. Without it an origin-less native
359
+ // client started an renv restore in the battery.
360
+ if (type === "project_action" && !PAGE_ONLY_CLASSES.includes(rec.class)) {
361
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, ok: false, reason: "class",
362
+ error: "Only the local notebook page may change the project environment." }));
363
+ audit("project-action-refused", { reason: "class", class: rec.class || "unknown" });
364
+ return undefined;
365
+ }
366
+ switch (type) {
367
+ case "exec": return plane.exec(cmd, rec);
368
+ case "interrupt": return plane.interrupt(cmd, rec);
369
+ // Both carry an OPTIONAL engine. `restart` with none is the primary
370
+ // engine's — which is what "Restart R" in the page means and what every
371
+ // client built before there was a second engine sends (§6.5).
372
+ 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;
374
+ case "runstate": return plane.runstate(cmd, rec);
375
+ case "runs": return plane.runs(cmd, rec);
376
+ case "adopt": return plane.adopt(cmd, rec);
377
+ case "input_reply": return plane.inputReply(cmd, rec);
378
+ case "debug_cmd": return plane.debugCmd(cmd, rec);
379
+ default: break;
380
+ }
381
+ const owner = opOwner.get(type);
382
+ if (owner) {
383
+ if (rec.role === "mcp" && (owner.agentRefused || []).includes(type)) {
384
+ audit("mcp-refused", { reason: `agent asked for ${type}` });
385
+ // serve.R gives each refused op its OWN sentence ("Agents cannot list
386
+ // the user's sessions."), so a plane may name one — through either
387
+ // hook name the planes use — and the generic line is the fallback.
388
+ const named = (typeof owner.agentRefusalFor === "function" && owner.agentRefusalFor(type))
389
+ || (typeof owner.agentReason === "function" && owner.agentReason(type));
390
+ const why = named || "Agents may not use this op.";
391
+ if (scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: why }));
392
+ return undefined;
393
+ }
394
+ // A plane may declare `audits: true` and write its own line instead: the
395
+ // journal wants the document KEY on it, and `ai-audit` must write exactly
396
+ // the record the page reported and nothing else — a generic op line there
397
+ // would put a forged-looking `{"event":"ai-audit"}` into the very stream
398
+ // `ai-audit-read` reads back.
399
+ if (owner.audits !== true) audit(type, { id: scalarChr(cmd.id) ? cmd.id : "" });
400
+ return owner.handle(cmd, rec, ctx, message);
401
+ }
402
+ if (FORWARDED.includes(type)) return plane.request(cmd, rec);
403
+ const family = NOT_YET[type] ?? NOT_YET[type.split(/[_-]/)[0]];
404
+ if (family !== undefined) {
405
+ if (family && scalarChr(cmd.id)) rec.ws.send(enc({ type, id: cmd.id, error: `Beatrina does not serve ${family} yet.`, code: "unsupported" }));
406
+ audit("unsupported-command", { detail: type });
407
+ return undefined;
408
+ }
409
+ audit("unknown-command", { detail: type });
410
+ return undefined;
411
+ }
412
+
413
+ /** Close and forget sockets that have stopped answering (only those that beat). */
414
+ const silenceMs = Math.max(0, Number(process.env.CARMAR_SOCKET_SILENCE ?? 90)) * 1000;
415
+ const reaper = setInterval(() => {
416
+ if (!silenceMs || !sockets.length) return;
417
+ const now = Date.now();
418
+ for (const rec of [...sockets]) {
419
+ if (rec.beats && now - rec.lastSeen >= silenceMs) {
420
+ audit("socket-silent", { role: rec.role, silence: silenceMs / 1000 });
421
+ const at = sockets.indexOf(rec);
422
+ if (at >= 0) sockets.splice(at, 1);
423
+ rec.ws.close(1001, "silent");
424
+ }
425
+ }
426
+ }, 5000);
427
+ reaper.unref();
428
+
429
+ return {
430
+ server,
431
+ sockets,
432
+ approvals,
433
+ state,
434
+ sessionStamp,
435
+ ctx,
436
+ /** Every verb the registered planes serve — merged into ready.commands by main.mjs. */
437
+ commands: planes.flatMap((pl) => pl.commands || []),
438
+ capabilities: planes.flatMap((pl) => pl.capabilities || []),
439
+ close: () => { clearInterval(reaper); sockets.forEach((r) => r.ws.close(1001, "shutdown")); server.close(); },
440
+ };
441
+ }