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,397 @@
1
+ // analyze.mjs — the analysis plane: children that READ source and never run
2
+ // it, one per language — spike/analyze.R and engines/python/analyze.py — each
3
+ // with its own queue, its own slot and its own routes. A request's `engine`
4
+ // picks the child; an ABSENT engine is R, so every page that predates Python
5
+ // analysis reaches analyze.R exactly as before.
6
+ //
7
+ // A port of spike/serve.R's analyzer half (`ANALYZE_FORWARDED`,
8
+ // `ensure_analyzer`, `route_analyze`, `enqueue_analyze_command`,
9
+ // `dispatch_analyze_queue`, `pump_analyzer`, `fail_analyze_routes`), driving
10
+ // the SAME spike/analyze.R, generalised to one lane per language
11
+ // (docs/wp/wp8a-python-analyzer.md).
12
+ //
13
+ // Why it is a separate process, and therefore a separate plane: worker.R reads
14
+ // its stdin only BETWEEN expressions, so a forty-second model fit takes syntax
15
+ // checking, completion and hover down with it. The split is the whole feature —
16
+ // "intelligence must not depend on the execution kernel being idle"
17
+ // (docs/stages/stage-2-intelligence-v1.md). The queue and the routes are
18
+ // separate for the mirror reason: a stuck analysis must never block a run, and
19
+ // a stuck run must never block an analysis. And the lanes are separate from
20
+ // EACH OTHER for the same reason one level down: a slow Python parse must not
21
+ // hold the slot an R diagnostic is waiting for.
22
+ //
23
+ // THE ONE RULE, inherited from spike/analyze.R and not weakened here: nothing
24
+ // in an analysis child may evaluate user source. Both are spawned in BATCH mode
25
+ // (`Rscript analyze.R <sentinel>`, `python -I analyze.py <sentinel>`) — no
26
+ // interactive console, no raw console channel, no debugger, no `exec`. The only
27
+ // commands this plane will ever write to them are the four below.
28
+ //
29
+ // Two more rules that only look like plumbing:
30
+ //
31
+ // · THE WIRE NAME IS THE ANALYZER'S, NOT THE BROWSER'S. The page says
32
+ // `analyze_ping` / `analyze_workspace` / `analyze_workspace_references`;
33
+ // the analyzers answer `ping` / `workspace` / `workspace_references`. The
34
+ // plane renames on the way DOWN and records the reply type it expects,
35
+ // because the browser waits on the type it asked for (`analyze_ping` →
36
+ // `pong`). Guessing the reply type from the frame would break the moment a
37
+ // reply carried an error instead.
38
+ // · FRAMES ARE RELAYED AS THE CHILD'S OWN BYTES. An analyze reply is full of
39
+ // deliberately-nullable fields — `failed` (null on success), `line`/`col`
40
+ // on a diagnostic the parser could not place — and a re-encode turns
41
+ // "nothing went wrong" into something else. `relayFrame` splices the
42
+ // routing id into the child's own bytes and changes nothing else.
43
+ //
44
+ // RELEASE THE SLOT BEFORE ASKING WHO WANTED THE ANSWER. These are two
45
+ // different questions and serve.R answered them in the wrong order once: a
46
+ // socket closing drops its routes including the one in flight, so the reply
47
+ // found no route, `next`-ed, and the dispatch slot was never freed — the
48
+ // analysis queue wedged for the life of the process while a healthy analyzer
49
+ // sat idle (docs/stability-plan.md S4). Kept in the right order here, per lane,
50
+ // with the same `===` guard so a LATE reply from a superseded request cannot
51
+ // free a slot that now belongs to another.
52
+
53
+ import fs from "node:fs";
54
+ import path from "node:path";
55
+ import { REngine } from "../engine-r.mjs";
56
+ import { StdioEngine } from "../engine-stdio.mjs";
57
+ import { relayFrame } from "../worker-plane.mjs";
58
+
59
+ /** The four ops the browser may send. An allow-list, deliberately tiny. */
60
+ export const ANALYZE_FORWARDED = Object.freeze(["analyze", "analyze_ping", "analyze_workspace",
61
+ "analyze_workspace_references"]);
62
+ /** Browser vocabulary → analyzer vocabulary, on the way down. */
63
+ export const ANALYZE_WIRE_NAME = Object.freeze({
64
+ analyze_ping: "ping", analyze_workspace: "workspace",
65
+ analyze_workspace_references: "workspace_references",
66
+ });
67
+ /** The frame type the browser is waiting on, recorded rather than guessed. */
68
+ export const ANALYZE_REPLY_TYPE = Object.freeze({
69
+ analyze: "analyze", analyze_ping: "pong", analyze_workspace: "workspace",
70
+ analyze_workspace_references: "workspace_references",
71
+ });
72
+ /**
73
+ * How long one analysis may hold its lane's slot before the plane gives up on it.
74
+ *
75
+ * serve.R has no such bound: analysis is milliseconds and the queue is nearly
76
+ * always empty, so a wedged child simply wedged the plane. 120 s is above the
77
+ * widest legitimate answer — `CARMAR_ANALYZE_BUDGET` clamps a workspace scan to
78
+ * 60 s — and far below "the editor has no diagnostics and nobody said why".
79
+ * Expiry fails the route AND kills the child, because a parse that did not
80
+ * return in two minutes will not return; the next request spawns a fresh one.
81
+ */
82
+ export const ANALYZE_DEADLINE_S = 120;
83
+
84
+ const scalarChr = (x) => typeof x === "string" && x.length > 0;
85
+ /** What a Python child must not inherit (host/engine-python.mjs's list, restated). */
86
+ const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
87
+
88
+ /**
89
+ * The Python analysis child: `python -I engines/python/analyze.py <sentinel>`.
90
+ *
91
+ * BATCH, exactly as analyze.R is spawned: plain JSON lines in, sentinel frames
92
+ * out, no console, no debugger, no `exec`. `-I` isolates it — no PYTHONPATH, no
93
+ * user site-packages, no script directory on sys.path — because the child
94
+ * imports the standard library only, and a path that lets a user's module be
95
+ * imported is a path that lets a user's module run.
96
+ */
97
+ export class PythonAnalysisEngine extends StdioEngine {
98
+ /**
99
+ * @param {Object} opts
100
+ * @param {string} opts.workerPath engines/python/analyze.py
101
+ * @param {string} opts.python the interpreter the host's discovery chose
102
+ */
103
+ constructor({ workerPath, python } = {}) {
104
+ super({ language: "python", workerPath });
105
+ if (!python) throw new Error("PythonAnalysisEngine: no Python interpreter");
106
+ this.python = python;
107
+ this.mode = "batch";
108
+ }
109
+
110
+ get label() { return "Python"; }
111
+ get stripEnv() { return STRIP_PY_ENV; }
112
+ get canInput() { return false; }
113
+
114
+ spawnPlan() {
115
+ return { bin: this.python, args: ["-I", path.resolve(this.workerPath), this.sentinel],
116
+ env: { CARMAR_WORKER_MODE: "batch" } };
117
+ }
118
+
119
+ /** Plain JSON lines, as analyze.R's batch reader takes them: no console to tag into. */
120
+ send(cmd) {
121
+ if (!cmd || typeof cmd.type !== "string") throw new Error("PythonAnalysisEngine.send: a command needs a string type");
122
+ return this.write(`${JSON.stringify(cmd)}\n`);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Which analyzer a request is for. An ABSENT engine is R — every page built
128
+ * before Python analysis existed sends none, and must reach analyze.R exactly
129
+ * as before. Lower-cased; "" (a fence that named no engine) stays "" so it is
130
+ * refused in words rather than folded into R.
131
+ */
132
+ export function analyzeEngineOf(cmd) {
133
+ if (!cmd || cmd.engine == null) return "r";
134
+ return String(cmd.engine).trim().toLowerCase();
135
+ }
136
+
137
+ export function createPlane({ root, spike, env, audit }) {
138
+ const deadlineS = (() => {
139
+ const raw = Number(env("CARMAR_ANALYZE_DEADLINE", String(ANALYZE_DEADLINE_S)));
140
+ return Number.isFinite(raw) && raw >= 0 ? raw : ANALYZE_DEADLINE_S;
141
+ })();
142
+ const hostRoot = root || path.resolve(spike, "..");
143
+ let seq = 0;
144
+
145
+ const enc = (obj) => JSON.stringify(obj);
146
+ const deliver = (rec, frame) => {
147
+ try { rec.ws.send(enc(frame)); } catch { /* the socket went away mid-send */ }
148
+ };
149
+
150
+ /**
151
+ * One analyzer: its own child, queue, slot and deadline.
152
+ *
153
+ * @param {Object} o
154
+ * @param {string} o.name "r" | "python" (the audit's engine field)
155
+ * @param {string} o.label the word the deadline sentence uses
156
+ * @param {() => (StdioEngine|{error: string})} o.makeEngine
157
+ * an engine to start, or `{error}` — a sentence for why there is none
158
+ */
159
+ function createLane({ name, label, makeEngine }) {
160
+ let engine = null;
161
+ let engineStarted = false; // an engine object that has ever been spawned
162
+ let analyzerPid = null;
163
+ const routes = new Map(); // wireId → {rec, clientId, responseType}
164
+ let queue = [];
165
+ let active = null; // wireId
166
+ let activeSince = null;
167
+ // R's audit events keep their exact shape; the Python lane names itself.
168
+ const laneAudit = (event, fields = {}) => audit(event, name === "r" ? fields : { engine: name, ...fields });
169
+
170
+ const dropRoute = (wireId) => { routes.delete(wireId); };
171
+ const releaseSlot = () => { active = null; activeSince = null; };
172
+
173
+ /** Every outstanding analysis is told, by name, that it will never arrive. */
174
+ const failRoutes = (reason) => {
175
+ for (const [wireId, route] of [...routes]) {
176
+ deliver(route.rec, { type: route.responseType, id: route.clientId, error: reason });
177
+ dropRoute(wireId);
178
+ }
179
+ };
180
+
181
+ const onEvent = (e) => {
182
+ // The analyzers write nothing to stdout but control frames, so anything
183
+ // without an id is a boot notice, not a user's output.
184
+ if (e.type === "analyze-ready") {
185
+ analyzerPid = e.pid ?? null;
186
+ laneAudit("analyzer-ready", { pid: analyzerPid });
187
+ return;
188
+ }
189
+ if (!scalarChr(e.id)) return;
190
+ const wireId = e.id;
191
+ if (active === wireId) releaseSlot(); // BEFORE the route lookup: S4
192
+ const route = routes.get(wireId);
193
+ if (route) {
194
+ try {
195
+ route.rec.ws.send(relayFrame({ ...e, id: route.clientId }, wireId, route.clientId));
196
+ } catch { /* the asker's socket went away */ }
197
+ dropRoute(wireId);
198
+ }
199
+ dispatch();
200
+ };
201
+
202
+ const onExit = () => {
203
+ // Anything still in flight will never be answered by this child.
204
+ failRoutes("the analysis worker stopped");
205
+ releaseSlot();
206
+ queue = [];
207
+ laneAudit("analyzer-stopped", { pid: analyzerPid });
208
+ analyzerPid = null;
209
+ };
210
+
211
+ /**
212
+ * The child, spawned on FIRST USE rather than at startup: a notebook that
213
+ * never asks for a diagnostic should not pay for a second process, and a
214
+ * lazily started one also restarts itself for free after a crash.
215
+ * @returns {{engine: StdioEngine|null, error: string}}
216
+ */
217
+ const ensureEngine = () => {
218
+ if (engine && engine.alive) return { engine, error: "" };
219
+ let made = null;
220
+ try { made = makeEngine(); } catch (err) {
221
+ laneAudit("analyzer-start-failed", { detail: err.message });
222
+ return { engine: null, error: "the analysis worker could not start" };
223
+ }
224
+ if (made && made.error) return { engine: null, error: made.error };
225
+ if (engineStarted) laneAudit("analyzer-restart", {});
226
+ const mine = made;
227
+ try {
228
+ mine.on("event", (e) => { if (engine === mine) onEvent(e); });
229
+ mine.on("exit", () => { if (engine === mine) onExit(); });
230
+ mine.on("spawn-error", (err) => laneAudit("analyzer-start-failed", { detail: err.message }));
231
+ mine.start();
232
+ } catch (err) {
233
+ laneAudit("analyzer-start-failed", { detail: err.message });
234
+ return { engine: null, error: "the analysis worker could not start" };
235
+ }
236
+ engine = mine;
237
+ engineStarted = true;
238
+ return { engine, error: "" };
239
+ };
240
+
241
+ /**
242
+ * One in flight at a time, like the worker queue: the analyzer is a single
243
+ * process, so a second large source written while it is mid-parse could
244
+ * block the host inside a full stdin pipe.
245
+ */
246
+ const dispatch = () => {
247
+ if (active !== null || !queue.length) return;
248
+ const { engine: a, error } = ensureEngine();
249
+ const item = queue.shift();
250
+ const route = routes.get(item.wireId);
251
+ if (!a) {
252
+ if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error });
253
+ dropRoute(item.wireId);
254
+ dispatch();
255
+ return;
256
+ }
257
+ active = item.wireId;
258
+ activeSince = Date.now();
259
+ let failed = null;
260
+ try { a.send(item.cmd); } catch (err) { failed = err.message; }
261
+ if (failed) {
262
+ if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error: failed });
263
+ dropRoute(item.wireId);
264
+ releaseSlot();
265
+ dispatch();
266
+ }
267
+ };
268
+
269
+ /** A wedged child holds the slot; after the deadline it holds nothing. */
270
+ const deadlineCheck = () => {
271
+ if (!deadlineS || active === null || activeSince == null) return;
272
+ if (Date.now() - activeSince < deadlineS * 1000) return;
273
+ const wireId = active;
274
+ const route = routes.get(wireId);
275
+ const reason = `The analysis worker did not answer within ${Math.round(deadlineS)} seconds. `
276
+ + `Your ${label} session and its variables are untouched; only this analysis was abandoned.`;
277
+ laneAudit("analyzer-deadline", { id: wireId, seconds: Math.round(deadlineS) });
278
+ if (route) deliver(route.rec, { type: route.responseType, id: route.clientId, error: reason });
279
+ dropRoute(wireId);
280
+ releaseSlot();
281
+ // The child is not merely slow, it is stuck; its exit clears the rest and
282
+ // the next request spawns a fresh one.
283
+ if (engine && engine.alive) engine.kill();
284
+ };
285
+
286
+ const kill = () => {
287
+ if (!engine || !engine.alive) return;
288
+ try { engine.proc.stdin.end(); } catch { /* already closed */ }
289
+ try { engine.kill(); } catch { /* already gone */ }
290
+ };
291
+
292
+ return {
293
+ enqueue(down, route, wireId) {
294
+ routes.set(wireId, route);
295
+ queue.push({ cmd: down, wireId });
296
+ dispatch();
297
+ },
298
+ onClose(rec) {
299
+ const abandoned = new Set(queue.filter((item) => routes.get(item.wireId)?.rec === rec).map((i) => i.wireId));
300
+ if (abandoned.size) queue = queue.filter((item) => !abandoned.has(item.wireId));
301
+ for (const [wireId, route] of [...routes]) if (route.rec === rec) dropRoute(wireId);
302
+ // The in-flight route is dropped with the rest; its reply releases the
303
+ // slot on arrival (onEvent releases BEFORE it looks a route up).
304
+ },
305
+ deadlineCheck,
306
+ kill,
307
+ get pid() { return analyzerPid; },
308
+ get depth() { return queue.length; },
309
+ };
310
+ }
311
+
312
+ // ── the lanes ─────────────────────────────────────────────────────────────
313
+ const rWorkerPath = path.join(spike, "analyze.R");
314
+ const pyWorkerPath = path.join(hostRoot, "engines", "python", "analyze.py");
315
+ /** The engine pool, learned at init: which Python this host discovered. */
316
+ let pool = null;
317
+
318
+ const lanes = {
319
+ r: createLane({ name: "r", label: "R", makeEngine: () => new REngine({ workerPath: rWorkerPath, mode: "batch" }) }),
320
+ python: createLane({
321
+ name: "python", label: "Python",
322
+ makeEngine: () => {
323
+ // The SAME interpreter the Python engine runs on — the pool's own
324
+ // discovery, including CARMAR_PYTHON and CARMAR_NO_PYTHON — so "Python
325
+ // is available" means one thing on this host, and its reason is the
326
+ // one Doctor shows.
327
+ const spec = pool && Array.isArray(pool.specs) ? pool.specs.find((x) => x.name === "python") : null;
328
+ if (!spec || !spec.available || !spec.runtime || !spec.runtime.path) {
329
+ const why = spec && spec.detail ? spec.detail : "No Python interpreter was found.";
330
+ return { error: `Python analysis is unavailable: ${why}` };
331
+ }
332
+ if (!fs.existsSync(pyWorkerPath)) {
333
+ return { error: "Python analysis is unavailable: this host does not carry engines/python/analyze.py." };
334
+ }
335
+ return new PythonAnalysisEngine({ workerPath: pyWorkerPath, python: spec.runtime.path });
336
+ },
337
+ }),
338
+ };
339
+
340
+ const sweeper = setInterval(() => { Object.values(lanes).forEach((lane) => lane.deadlineCheck()); }, 500);
341
+ sweeper.unref();
342
+ const stop = () => Object.values(lanes).forEach((lane) => lane.kill());
343
+
344
+ return {
345
+ name: "analyze",
346
+ ops: [...ANALYZE_FORWARDED],
347
+ agentRefused: [], // serve.R refuses none of these to an agent:
348
+ // reading source without running it is what an
349
+ // agent should be doing.
350
+ // NOT advertised in ready.commands, and that is deliberate parity: serve.R
351
+ // never merged the analyze ops there, so no page decides anything by their
352
+ // presence. The capability below is the one new fact a page may test for.
353
+ commands: [],
354
+ capabilities: ["analyze-python-v1"],
355
+
356
+ init(ctx) {
357
+ pool = ctx && ctx.plane ? ctx.plane : null;
358
+ // The analysis children are spawned lazily, so there is nothing to start
359
+ // here — only the promise that they do not outlive this process. Each is
360
+ // its own process group, and an orphan holding the user's files is
361
+ // exactly what the worker plane's exit handler exists to prevent.
362
+ process.on("exit", stop);
363
+ },
364
+
365
+ handle(cmd, rec, ctx) {
366
+ if (!scalarChr(cmd.id)) return undefined;
367
+ if (!pool && ctx && ctx.plane) pool = ctx.plane;
368
+ const responseType = ANALYZE_REPLY_TYPE[cmd.type] || "analyze";
369
+ const engineName = analyzeEngineOf(cmd);
370
+ const lane = Object.hasOwn(lanes, engineName) ? lanes[engineName] : null;
371
+ if (!lane) {
372
+ // Answered, never dropped: a page waiting on an id must not hang
373
+ // because it asked about a language nothing here reads.
374
+ deliver(rec, { type: responseType, id: cmd.id,
375
+ error: engineName ? `No analyzer reads ${engineName} code.` : "A chunk that names no engine is not analysed." });
376
+ return undefined;
377
+ }
378
+ seq += 1;
379
+ const wireId = `awire-${seq}`;
380
+ // The routing key stays here: the children never see `engine`, so an R
381
+ // request reaches analyze.R byte-for-byte as it did.
382
+ const { engine: _routed, ...rest } = cmd;
383
+ const down = { ...rest, id: wireId, type: ANALYZE_WIRE_NAME[cmd.type] || cmd.type };
384
+ lane.enqueue(down, { rec, clientId: cmd.id, responseType }, wireId);
385
+ return undefined;
386
+ },
387
+
388
+ /** Spend no analyzer time on commands nobody will receive. */
389
+ onClose(rec) { Object.values(lanes).forEach((lane) => lane.onClose(rec)); },
390
+
391
+ // ── what the tests need to see, and nothing the wire can reach ────────
392
+ get analyzerPid() { return lanes.r.pid; },
393
+ get pythonAnalyzerPid() { return lanes.python.pid; },
394
+ get queueDepth() { return lanes.r.depth + lanes.python.depth; },
395
+ stop,
396
+ };
397
+ }