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,383 @@
1
+ // engine-pool.mjs — more than one engine in one document (WP4).
2
+ //
3
+ // `host/worker-plane.mjs` is ONE engine behind ONE queue, and that is right:
4
+ // R and Python are two processes with two memories and there is no bridge
5
+ // (docs/worker-protocol.md §6.1). What was missing is the router above them.
6
+ // This is that router, and it presents the plane's OWN interface to
7
+ // host/server.mjs, so the server is unchanged except where it has a genuine
8
+ // choice to make.
9
+ //
10
+ // The rules it implements, each from docs/worker-protocol.md §6:
11
+ //
12
+ // §6.1 One session per engine. Two planes, two processes, no shared names.
13
+ // §6.2 One queue per engine — so a Python chunk and an R chunk can be in
14
+ // flight at once, while chunks of one engine stay serial. (Run All's
15
+ // reading-order walk is the PAGE's; it dispatches the next chunk only
16
+ // after the previous one finished, whatever the engines.)
17
+ // §6.3 Stop, input and a debugger step belong to the RUN's engine, found by
18
+ // the run's id. A page never names an engine on a control op.
19
+ // §6.4 A data viewer belongs to the engine that produced the frame: `view`,
20
+ // `colstats` and the paging requests carry `engine`.
21
+ // §6.5 Restart is per engine. `restart {engine}` replaces that one process.
22
+ // §6.6 The working directory is per engine, reported by each.
23
+ // §6.9 An absent engine is a STATED FACT: a chunk whose engine has no
24
+ // runtime ends with a done frame that says so, never a hang.
25
+ //
26
+ // ON DEMAND, EXCEPT R. R starts at boot because the page will not leave
27
+ // "connecting" until a `ready` arrives and because every existing suite
28
+ // expects a session to be there. Every other engine starts the first time a
29
+ // chunk of its language runs — an ipykernel that nobody asked for is 1.3
30
+ // seconds and ~60 MB spent on a document that may hold no Python at all.
31
+
32
+ import { EventEmitter } from "node:events";
33
+ import { WorkerPlane } from "./worker-plane.mjs";
34
+
35
+ const enc = (obj) => JSON.stringify(obj);
36
+ const scalarChr = (x) => typeof x === "string" && x.length > 0;
37
+
38
+ /** The engine key a value means. An ABSENT engine is the default one. */
39
+ export function engineKey(engine, fallback = "r") {
40
+ if (engine == null) return fallback;
41
+ const key = String(engine).toLowerCase();
42
+ if (key === "py") return "python";
43
+ // knitr's JavaScript engine is `{js}`; `{javascript}` and `{node}` are the
44
+ // spellings people also write.
45
+ return key === "javascript" || key === "node" ? "js" : key;
46
+ }
47
+
48
+ export class EnginePool extends EventEmitter {
49
+ /**
50
+ * @param {Object} opts
51
+ * @param {Array<{name:string,label:string,factory:Function,boot?:"eager"|"on-demand",
52
+ * available?:boolean,detail?:string,runtime?:object}>} opts.specs
53
+ * Engines this host knows about. A spec with `available: false` is
54
+ * still listed — it is the thing that lets a chunk be refused with a
55
+ * reason instead of a hang.
56
+ * @param {(event:string, fields?:object) => void} [opts.audit]
57
+ * @param {number} [opts.deadlineScale]
58
+ * @param {number} [opts.respawnMax]
59
+ * @param {(hello:object, spec:object) => object} [opts.decorateReady]
60
+ */
61
+ constructor({ specs, audit = () => {}, deadlineScale = 1, respawnMax = 10, decorateReady = (x) => x }) {
62
+ super();
63
+ this.specs = specs;
64
+ this.audit = audit;
65
+ this.decorateReady = decorateReady;
66
+ this.planes = new Map();
67
+ this.starting = new Map(); // engine → Promise, so two chunks start one engine
68
+ this.sockets = () => [];
69
+ this.primaryName = specs[0] ? specs[0].name : "r";
70
+ for (const spec of specs) {
71
+ if (!spec.available) continue;
72
+ const plane = new WorkerPlane({
73
+ engineFactory: spec.factory, name: spec.name, label: spec.label,
74
+ audit: (event, fields = {}) => audit(event, { engine: spec.name, ...fields }),
75
+ deadlineScale, respawnMax,
76
+ decorateReady: (frame) => this.onReady(spec, frame),
77
+ });
78
+ plane.sockets = () => this.sockets();
79
+ plane.on("broadcast", (payload) => this.emit("broadcast", payload));
80
+ this.planes.set(spec.name, plane);
81
+ }
82
+ }
83
+
84
+ /** The plane for an engine, or null when this host has no such engine. */
85
+ planeFor(name) { return this.planes.get(engineKey(name, this.primaryName)) || null; }
86
+
87
+ get primary() { return this.planes.get(this.primaryName) || null; }
88
+
89
+ /** The names every advertised engine has — what the page's `engines` list is. */
90
+ get engineNames() { return this.specs.filter((s) => s.available).map((s) => s.name); }
91
+
92
+ /** Which engines are actually running right now (the rest start on demand). */
93
+ get startedNames() { return [...this.planes].filter(([, p]) => p.engine != null).map(([n]) => n); }
94
+
95
+ /**
96
+ * One row per engine this host knows about, available or not — the shape the
97
+ * ready frame and Doctor both carry, so "what was searched" is one answer.
98
+ */
99
+ engineRows() {
100
+ return this.specs.map((spec) => {
101
+ const plane = this.planes.get(spec.name) || null;
102
+ return {
103
+ engine: spec.name, label: spec.label, available: Boolean(spec.available),
104
+ detail: spec.detail || "", runtime: spec.runtime || null,
105
+ started: Boolean(plane && plane.engine), alive: Boolean(plane && plane.alive),
106
+ pid: plane && plane.engine ? plane.pid : null,
107
+ version: spec.version || "",
108
+ };
109
+ });
110
+ }
111
+
112
+ // ── the primary plane's surface, which server.mjs speaks ──────────────────
113
+
114
+ get alive() { return Boolean(this.primary && this.primary.alive); }
115
+ get language() { return this.primaryName; }
116
+ get readyFrame() { return this.primary ? this.primary.readyFrame : null; }
117
+ get hello() { return this.primary ? this.primary.hello : null; }
118
+ get notice() { return this.primary ? this.primary.notice : null; }
119
+ /** Every engine's in-flight execs, so the idle clock counts them all. */
120
+ /** Every engine forgets the results nobody came back for. */
121
+ sweepParked(lingerS) { for (const p of this.planes.values()) p.sweepParked?.(lingerS); }
122
+
123
+ get running() {
124
+ const all = new Set();
125
+ for (const [name, plane] of this.planes) for (const id of plane.running) all.add(`${name}:${id}`);
126
+ return all;
127
+ }
128
+ /**
129
+ * The PRIMARY engine — what every plane written against a single WorkerPlane
130
+ * reads (`plane.engine.rscript` in sessions, `plane.engine.alive`). The
131
+ * first shape of this getter returned a synthetic {alive, kill} over every
132
+ * started engine, which made `r_versions` report no current R and the file
133
+ * ops lose the worker's cwd; `killAll()` is the every-engine door now.
134
+ */
135
+ get engine() { return this.primary ? this.primary.engine : null; }
136
+ /** The primary engine's working directory, as it last reported it. */
137
+ get workerWd() { return this.primary ? this.primary.workerWd : process.cwd(); }
138
+ get mode() { return this.primary ? this.primary.mode : "batch"; }
139
+ get pid() { return this.primary ? this.primary.pid : null; }
140
+ /** main.mjs's exit handler: every started engine must go. */
141
+ killAll() {
142
+ for (const p of this.planes.values()) { if (p.engine && p.alive) { try { p.engine.kill(); } catch { /* gone */ } } }
143
+ }
144
+
145
+ async start() {
146
+ const eager = this.specs.filter((s) => s.available && s.boot !== "on-demand");
147
+ for (const spec of eager) await this.planes.get(spec.name).start();
148
+ }
149
+
150
+ /**
151
+ * Bring an engine up if it is not already, once.
152
+ * @returns {Promise<{ok: boolean, plane?: WorkerPlane, message?: string}>}
153
+ */
154
+ async ensure(name) {
155
+ const key = engineKey(name, this.primaryName);
156
+ const spec = this.specs.find((s) => s.name === key);
157
+ if (!spec) {
158
+ return { ok: false, message: `Beatrina has no ${key} engine; this chunk was not run.` };
159
+ }
160
+ if (!spec.available) {
161
+ // §6.9, word for word where it can be: the fact, then where to look.
162
+ return { ok: false, message: `${spec.label} is not installed or was not found; this chunk was not run. `
163
+ + `${spec.detail || ""} Doctor lists what was searched.`.replace(/\s+/g, " ").trim() };
164
+ }
165
+ const plane = this.planes.get(key);
166
+ if (plane.engine) return { ok: true, plane };
167
+ if (!this.starting.has(key)) {
168
+ this.audit("engine-start", { engine: key });
169
+ this.starting.set(key, plane.start().finally(() => this.starting.delete(key)));
170
+ }
171
+ try { await this.starting.get(key); } catch (e) {
172
+ return { ok: false, message: `The ${spec.label} engine would not start: ${e.message}` };
173
+ }
174
+ // The breakpoints go in BEFORE the command that started the engine: a
175
+ // start resolves once the process exists, so the exec that follows is
176
+ // written to its stdin next, and a replay on `ready` alone arrived after it.
177
+ this.replayBreaks(key);
178
+ return { ok: true, plane };
179
+ }
180
+
181
+ /**
182
+ * The ready frame of ONE engine, on its way to the page.
183
+ *
184
+ * A second engine does NOT send `ready`. `src/r-kernel.js` treats `ready` as
185
+ * the SESSION's identity — it re-runs the compatibility gate, resolves every
186
+ * readyWaiter and re-pumps the queue — so a second one arriving mid-session
187
+ * would read as the whole kernel having restarted. The engine is announced
188
+ * under its own type instead, carrying the same fields plus `engine`.
189
+ * Returning it from here (rather than emitting it separately) is what makes
190
+ * the plane store it as its `hello` and replay it to a page that connects
191
+ * later, which is the property the R ready has and the reason it is kept.
192
+ */
193
+ onReady(spec, frame) {
194
+ const decorated = this.decorateReady(frame, spec);
195
+ if (spec.name === this.primaryName) return decorated;
196
+ // A RESPAWNED engine knows no breakpoints (a first start is covered in
197
+ // ensure(), before its first command). Sending twice is harmless: the
198
+ // registry is state, not a count.
199
+ this.replayBreaks(spec.name);
200
+ return { ...decorated, type: "engine-ready", engine: spec.name };
201
+ }
202
+
203
+ // ── breakpoints ──────────────────────────────────────────────────────────
204
+ //
205
+ // The page sends `debug_breaks {file: "chunk:<stableId>", lines}` with no
206
+ // engine: lib/debugger.js addresses a CHUNK, and which engine runs that chunk
207
+ // is the document's business, not the gutter's. So the pool keeps the one
208
+ // registry, the primary engine answers the request exactly as it always did
209
+ // (R's reply, byte for byte), and every OTHER engine that has a debugger is
210
+ // told the same breakpoints — now if it is running, on its ready if it is not.
211
+ // Sent as a command with no id: nobody waits on it, and it must not queue
212
+ // behind a running chunk, which is precisely when someone toggles a
213
+ // breakpoint.
214
+
215
+ noteBreaks(cmd) {
216
+ if (!scalarChr(cmd.file)) return;
217
+ if (!this.breaks) this.breaks = new Map();
218
+ const lines = (Array.isArray(cmd.lines) ? cmd.lines : [])
219
+ .map(Number).filter((n) => Number.isInteger(n) && n >= 1);
220
+ if (lines.length) this.breaks.set(cmd.file, [...new Set(lines)].sort((a, b) => a - b));
221
+ else this.breaks.delete(cmd.file);
222
+ for (const [name, plane] of this.planes) {
223
+ if (name === this.primaryName || !plane.engine || !plane.canDebug) continue;
224
+ try { plane.engine.send({ type: "debug_breaks", file: cmd.file, lines: this.breaks.get(cmd.file) || [] }); }
225
+ catch { /* a dead engine is respawned and replayed on its ready */ }
226
+ }
227
+ }
228
+
229
+ replayBreaks(name) {
230
+ const plane = this.planes.get(name);
231
+ if (!this.breaks || !plane || !plane.engine || !plane.canDebug) return;
232
+ for (const [file, lines] of this.breaks) {
233
+ try { plane.engine.send({ type: "debug_breaks", file, lines }); } catch { return; }
234
+ }
235
+ }
236
+
237
+ /** A page that connects late is told about every engine already running. */
238
+ replay(ws) {
239
+ for (const [name, plane] of this.planes) {
240
+ if (name === this.primaryName) continue;
241
+ if (plane.hello) ws.send(plane.hello);
242
+ if (plane.notice) ws.send(plane.notice);
243
+ }
244
+ }
245
+
246
+ // ── routing ──────────────────────────────────────────────────────────────
247
+
248
+ exec(cmd, rec) {
249
+ if (!scalarChr(cmd.id) || typeof cmd.source !== "string") return undefined;
250
+ const key = engineKey(cmd.engine, this.primaryName);
251
+ const plane = this.planes.get(key);
252
+ if (plane && plane.engine) return plane.exec(cmd, rec);
253
+ // An engine that is not up yet: start it, THEN dispatch. The page sees a
254
+ // slower first chunk and nothing else — no queue of its own, no state here.
255
+ this.ensure(key).then((r) => {
256
+ if (!r.ok) {
257
+ this.audit("engine-absent", { engine: key });
258
+ return rec.ws.send(enc({ type: "done", id: cmd.id, status: "error", message: r.message }));
259
+ }
260
+ return r.plane.exec(cmd, rec);
261
+ }).catch((e) => {
262
+ rec.ws.send(enc({ type: "done", id: cmd.id, status: "error", message: e.message }));
263
+ });
264
+ return undefined;
265
+ }
266
+
267
+ /**
268
+ * A request for one engine's session — `env`, `view`, `colstats`, `obj`, …
269
+ * §6.4: it carries `engine` when it is about a value; without one it is the
270
+ * primary engine's, which is what every existing page sends.
271
+ *
272
+ * An engine that has not started is NOT started by a request: a page asking
273
+ * a Python session for its environment before any Python has run is asking
274
+ * about a session that does not exist, and booting an ipykernel to answer
275
+ * "nothing" would be a 1.3-second surprise. The empty answer says so.
276
+ */
277
+ request(cmd, rec) {
278
+ if (cmd.type === "debug_breaks") this.noteBreaks(cmd);
279
+ const key = engineKey(cmd.engine, this.primaryName);
280
+ const plane = this.planes.get(key);
281
+ if (plane && plane.engine) return plane.request(cmd, rec);
282
+ if (!scalarChr(cmd.id)) return undefined;
283
+ const spec = this.specs.find((s) => s.name === key);
284
+ const why = !spec ? `Beatrina has no ${key} engine.`
285
+ : !spec.available ? `${spec.label} is not installed or was not found; Doctor lists what was searched.`
286
+ : `The ${spec.label} session has not started yet — run a ${key} chunk first.`;
287
+ rec.ws.send(enc({ type: cmd.type, id: cmd.id, error: why, code: "no-engine" }));
288
+ return undefined;
289
+ }
290
+
291
+ /** Every plane a control op could belong to, the run's owner first (§6.3). */
292
+ ownerOf(rec, clientId) {
293
+ for (const plane of this.planes.values()) {
294
+ if (plane.engine && plane.ownsRun(rec, clientId)) return plane;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ interrupt(cmd, rec) {
300
+ if (scalarChr(cmd.id)) {
301
+ const owner = this.ownerOf(rec, cmd.id);
302
+ if (owner) return owner.interrupt(cmd, rec);
303
+ // No plane owns it: a queued run this socket started may still be in a
304
+ // queue, and each plane drops what is not its own without signalling.
305
+ for (const plane of this.planes.values()) if (plane.engine) plane.interrupt(cmd, rec);
306
+ return undefined;
307
+ }
308
+ // An older bundle's id-less Stop: every engine that is busy.
309
+ for (const plane of this.planes.values()) if (plane.engine && plane.active) plane.interrupt(cmd, rec);
310
+ return undefined;
311
+ }
312
+
313
+ forceStop(rec, cmd = {}) {
314
+ const owner = scalarChr(cmd.id) ? this.ownerOf(rec, cmd.id) : null;
315
+ if (owner) return owner.forceStop(rec);
316
+ const named = scalarChr(cmd.engine) ? this.planes.get(engineKey(cmd.engine)) : null;
317
+ if (named && named.engine) return named.forceStop(rec);
318
+ // Neither a run nor an engine was named: force-stop every engine that is
319
+ // actually running something, and the primary if none is.
320
+ const busy = [...this.planes.values()].filter((p) => p.engine && p.active);
321
+ if (busy.length) { busy.forEach((p) => p.forceStop(rec)); return undefined; }
322
+ if (this.primary) this.primary.forceStop(rec);
323
+ return undefined;
324
+ }
325
+
326
+ /**
327
+ * Restart ONE engine (§6.5). `restart {engine: "python"}` replaces that
328
+ * process and fails that engine's routes; the other engine is untouched.
329
+ * With no engine named it is the primary's — which is what "Restart R" in
330
+ * the page means and what every existing client sends.
331
+ */
332
+ async restart(engine) {
333
+ const key = engineKey(engine, this.primaryName);
334
+ const plane = this.planes.get(key);
335
+ if (!plane) return undefined;
336
+ if (!plane.engine) {
337
+ // Restarting an engine that never started is "start it", not an error.
338
+ const r = await this.ensure(key);
339
+ return r.ok ? undefined : undefined;
340
+ }
341
+ return plane.restart();
342
+ }
343
+
344
+ inputReply(cmd, rec) {
345
+ const waiting = [...this.planes.values()].find((p) => p.engine && p.inputWaiting);
346
+ if (waiting) return waiting.inputReply(cmd, rec);
347
+ return this.primary ? this.primary.inputReply(cmd, rec) : undefined;
348
+ }
349
+
350
+ debugCmd(cmd, rec) {
351
+ const paused = [...this.planes.values()].find((p) => p.engine && p.debugPaused);
352
+ if (paused) return paused.debugCmd(cmd, rec);
353
+ return this.primary ? this.primary.debugCmd(cmd, rec) : undefined;
354
+ }
355
+
356
+ runstate(cmd, rec) {
357
+ if (!scalarChr(cmd.id)) return undefined;
358
+ const runId = scalarChr(cmd.run) ? cmd.run : "";
359
+ const running = Boolean(runId) && [...this.planes.values()].some((p) => p.engine && p.ownsRun(rec, runId));
360
+ rec.ws.send(enc({ type: "runstate", id: cmd.id, run: runId, running }));
361
+ return undefined;
362
+ }
363
+
364
+ /** Every engine's runs in one answer, each row naming its engine. */
365
+ runs(cmd, rec) {
366
+ if (!scalarChr(cmd.id)) return undefined;
367
+ const rows = [...this.planes.values()].filter((p) => p.engine).flatMap((p) => p.runRows(rec));
368
+ rec.ws.send(enc({ type: "runs", id: cmd.id, runs: rows }));
369
+ return undefined;
370
+ }
371
+
372
+ adopt(cmd, rec) {
373
+ if (!scalarChr(cmd.id) || !scalarChr(cmd.srcname)) return undefined;
374
+ const owner = [...this.planes.values()].find((p) => p.engine && p.hasAdoptable(cmd.srcname));
375
+ if (owner) return owner.adopt(cmd, rec);
376
+ rec.ws.send(enc({ type: "done", id: cmd.id, status: "lost", message: "that run is no longer in this session." }));
377
+ return undefined;
378
+ }
379
+
380
+ dropSocket(rec) { for (const plane of this.planes.values()) plane.dropSocket(rec); }
381
+
382
+ async stop() { for (const plane of this.planes.values()) await plane.stop(); }
383
+ }
@@ -0,0 +1,228 @@
1
+ // engine-python.mjs — the Python engine's adapter.
2
+ //
3
+ // The process behind it is `engines/python/worker.py`, which speaks the same
4
+ // wire `spike/worker.R` does (sentinel-framed JSON out, tagged JSON in). It
5
+ // evaluates nothing itself: an ipykernel one process further out does, and
6
+ // jupyter_client starts that kernel in its OWN session — measured on this
7
+ // machine 2026-09-14, kernel sid == kernel pid — so the group SIGINT this
8
+ // adapter sends reaches worker.py, which forwards `interrupt_request`. That is
9
+ // the arrangement we want: a signal cannot queue behind a running cell the way
10
+ // a stdin command can, and worker.py is never the process that is busy.
11
+ //
12
+ // DISCOVERY IS A STATED FACT, NEVER A HANG (docs/worker-protocol.md §6.9). A
13
+ // runtime that is missing, or present without ipykernel, is reported on the
14
+ // ready frame and in Doctor with the ladder that was searched — so a
15
+ // `{python}` chunk says what is wrong instead of waiting on nothing.
16
+
17
+ import { execFile } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { StdioEngine } from "./engine-stdio.mjs";
21
+ import { findInterruptTool } from "./windows-runtime.mjs";
22
+
23
+ /** How long a probe of a candidate interpreter may take before it is "no". */
24
+ export const PROBE_TIMEOUT_MS = 6000;
25
+
26
+ /** Python's own variables that would aim a child at a different install. */
27
+ const STRIP_PY_ENV = /^(PYTHONHOME|PYTHONPATH|PYTHONSTARTUP|PYTHONEXECUTABLE)$/;
28
+
29
+ const exists = (p) => Boolean(p) && fs.existsSync(p);
30
+
31
+ /**
32
+ * Every interpreter worth asking, in the order the answer is preferred, each
33
+ * labelled with WHY it was considered. The label is what Doctor prints, so a
34
+ * user who gets the wrong Python can see which rung chose it.
35
+ *
36
+ * `CARMAR_PYTHON` first (an explicit answer outranks a search), then the
37
+ * environment the user is already inside — an active venv, then conda — then
38
+ * pyenv's selected version, then PATH, then the usual installs. PATH is late
39
+ * for the reason `detectRscript` puts it last: a Finder-launched process has
40
+ * `/usr/bin:/bin:/usr/sbin:/sbin` and would miss Homebrew, conda and pyenv
41
+ * entirely, so a shell-launched and a Finder-launched session would silently
42
+ * pick different interpreters.
43
+ *
44
+ * @returns {Array<{path: string, why: string}>}
45
+ */
46
+ export function pythonCandidates(env = process.env, platform = process.platform, io = null) {
47
+ if (platform === "win32") return windowsPythonCandidates(env, io);
48
+ const binName = "python3";
49
+ const rungs = [];
50
+ const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
51
+ if (env.CARMAR_PYTHON) add(env.CARMAR_PYTHON, "CARMAR_PYTHON");
52
+ // An interpreter Beatrix installed for itself. Nothing creates this yet —
53
+ // WP7 packages it — but the rung is here so that when something does, it
54
+ // outranks whatever happens to be on the user's PATH, which is the whole
55
+ // point of shipping one.
56
+ if (env.HOME) add(path.join(env.HOME, ".carmar", "python", "bin", binName), "the interpreter Beatrina manages");
57
+ if (env.VIRTUAL_ENV) add(path.join(env.VIRTUAL_ENV, "bin", binName), "the active virtualenv (VIRTUAL_ENV)");
58
+ if (env.CONDA_PREFIX) add(path.join(env.CONDA_PREFIX, "bin", binName), "the active conda environment (CONDA_PREFIX)");
59
+ const pyenvRoot = env.PYENV_ROOT || (env.HOME ? path.join(env.HOME, ".pyenv") : "");
60
+ if (pyenvRoot) {
61
+ const versionFile = path.join(pyenvRoot, "version");
62
+ let selected = "";
63
+ try { selected = fs.readFileSync(versionFile, "utf8").trim().split(/\s+/)[0] || ""; } catch { selected = ""; }
64
+ if (selected) add(path.join(pyenvRoot, "versions", selected, "bin", binName), `pyenv's selected version (${selected})`);
65
+ add(path.join(pyenvRoot, "shims", binName), "a pyenv shim");
66
+ }
67
+ for (const dir of String(env.PATH || "").split(path.delimiter)) {
68
+ if (dir) add(path.join(dir, binName), "PATH");
69
+ }
70
+ for (const p of ["/opt/homebrew/bin/python3", "/usr/local/bin/python3", "/usr/bin/python3"]) {
71
+ add(p, "a standard install location");
72
+ }
73
+ return rungs;
74
+ }
75
+
76
+ /**
77
+ * The Windows ladder. A venv keeps its interpreter in `Scripts\python.exe`,
78
+ * conda at the prefix root, python.org's installer under
79
+ * `%LOCALAPPDATA%\Programs\Python\Python3xx`; `python3.exe` on PATH is often
80
+ * the Microsoft Store STUB, which opens the Store instead of running, so the
81
+ * real installs come before PATH and the stub is skipped by name.
82
+ */
83
+ export function windowsPythonCandidates(env = process.env, io = null) {
84
+ const w = path.win32;
85
+ const readdir = io ? io.readdir : (p) => { try { return fs.readdirSync(p); } catch { return []; } };
86
+ const rungs = [];
87
+ const add = (p, why) => { if (p && !rungs.some((r) => r.path === p)) rungs.push({ path: p, why }); };
88
+ if (env.CARMAR_PYTHON) add(env.CARMAR_PYTHON, "CARMAR_PYTHON");
89
+ if (env.USERPROFILE) add(w.join(env.USERPROFILE, ".carmar", "python", "python.exe"), "the interpreter Beatrina manages");
90
+ if (env.VIRTUAL_ENV) add(w.join(env.VIRTUAL_ENV, "Scripts", "python.exe"), "the active virtualenv (VIRTUAL_ENV)");
91
+ if (env.CONDA_PREFIX) add(w.join(env.CONDA_PREFIX, "python.exe"), "the active conda environment (CONDA_PREFIX)");
92
+ const versionOf = (d) => Number((String(d).match(/Python(\d+)/) || [0, 0])[1]);
93
+ for (const [root, why] of [
94
+ [env.LOCALAPPDATA ? w.join(env.LOCALAPPDATA, "Programs", "Python") : "", "python.org installer (per user)"],
95
+ [env.ProgramFiles || "", "python.org installer (all users)"],
96
+ ]) {
97
+ if (!root) continue;
98
+ const dirs = readdir(root).filter((d) => /^Python3\d+/.test(d)).sort((a, b) => versionOf(b) - versionOf(a));
99
+ for (const d of dirs) add(w.join(root, d, "python.exe"), `${why} (${d})`);
100
+ }
101
+ for (const dir of String(env.PATH || env.Path || "").split(";")) {
102
+ if (!dir || /\\WindowsApps\\?$/i.test(dir)) continue; // the Store stub
103
+ add(w.join(dir, "python.exe"), "PATH");
104
+ }
105
+ return rungs;
106
+ }
107
+
108
+ /** Ask one interpreter what it is and whether it can host a kernel. */
109
+ function probe(bin) {
110
+ return new Promise((resolve) => {
111
+ const script = "import json,sys\n"
112
+ + "out={'version':'.'.join(str(x) for x in sys.version_info[:3]),'executable':sys.executable,'missing':[]}\n"
113
+ + "for m in ('ipykernel','jupyter_client'):\n"
114
+ + " try:\n"
115
+ + " __import__(m)\n"
116
+ + " except Exception:\n"
117
+ + " out['missing'].append(m)\n"
118
+ + "print(json.dumps(out))\n";
119
+ execFile(bin, ["-c", script], { timeout: PROBE_TIMEOUT_MS, env: process.env },
120
+ (err, stdout) => {
121
+ if (err) return resolve(null);
122
+ try { resolve(JSON.parse(String(stdout).trim().split("\n").pop())); } catch { resolve(null); }
123
+ });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Find a Python that can host an ipykernel, and SAY WHAT WAS SEARCHED.
129
+ *
130
+ * The result is the same shape whether it succeeded or not, because both
131
+ * answers are shown in the same place: the `engines` row on the ready frame
132
+ * and Doctor. `searched` lists every rung that was actually executed, with the
133
+ * reason it was considered and what it said — a user with three Pythons and
134
+ * ipykernel in the wrong one can see that from the row.
135
+ *
136
+ * @returns {Promise<{available: boolean, path: string, version: string, why: string,
137
+ * detail: string, searched: Array<object>}>}
138
+ */
139
+ export async function detectPython(env = process.env) {
140
+ const searched = [];
141
+ let firstWorking = null;
142
+ for (const rung of pythonCandidates(env)) {
143
+ if (!exists(rung.path)) continue;
144
+ const info = await probe(rung.path);
145
+ if (!info) {
146
+ searched.push({ ...rung, ok: false, detail: "did not answer" });
147
+ continue;
148
+ }
149
+ const missing = Array.isArray(info.missing) ? info.missing : [];
150
+ searched.push({ ...rung, ok: missing.length === 0, version: info.version,
151
+ detail: missing.length ? `${info.version}, missing ${missing.join(" and ")}` : info.version });
152
+ if (!missing.length) {
153
+ firstWorking = { path: rung.path, version: info.version, why: rung.why };
154
+ break;
155
+ }
156
+ // Keep looking: a system Python without ipykernel is not the answer, but
157
+ // the venv two rungs down may be.
158
+ if (searched.length > 12) break; // a PATH of 40 dirs is not a search
159
+ }
160
+ if (firstWorking) {
161
+ return { available: true, ...firstWorking, detail: `Python ${firstWorking.version}`, searched };
162
+ }
163
+ const found = searched.filter((s) => s.version);
164
+ const detail = found.length
165
+ ? `Python is installed (${found.map((s) => `${s.version} at ${s.path}`).join("; ")}) but ipykernel is not. `
166
+ + "Install it with `python3 -m pip install ipykernel`, or point CARMAR_PYTHON at an interpreter that has it."
167
+ : "No Python interpreter was found. Set CARMAR_PYTHON to one, or install Python 3.";
168
+ return { available: false, path: "", version: "", why: "", detail, searched };
169
+ }
170
+
171
+ /** One `engines/python/worker.py` process. */
172
+ export class PythonEngine extends StdioEngine {
173
+ /**
174
+ * @param {Object} opts
175
+ * @param {string} opts.workerPath engines/python/worker.py
176
+ * @param {string} opts.python the interpreter detectPython chose
177
+ * @param {string} [opts.kernelName] the jupyter kernelspec (default python3)
178
+ * @param {Record<string,string>} [opts.env]
179
+ * @param {string} [opts.cwd]
180
+ */
181
+ constructor({ workerPath, python, kernelName = "python3", env = {}, cwd, platform = process.platform } = {}) {
182
+ super({ language: "python", workerPath, env, cwd, platform });
183
+ if (platform === "win32") this.interruptTool = findInterruptTool({ env: process.env });
184
+ if (!workerPath || !fs.existsSync(workerPath)) throw new Error(`PythonEngine: no worker at ${workerPath}`);
185
+ if (!python) throw new Error("PythonEngine: no Python found (set CARMAR_PYTHON)");
186
+ this.python = python;
187
+ this.kernelName = kernelName;
188
+ }
189
+
190
+ get label() { return "Python"; }
191
+ get stripEnv() { return STRIP_PY_ENV; }
192
+ /** `input()` works through input_request/input_reply. */
193
+ get canInput() { return true; }
194
+ /**
195
+ * A debugger lives in the kernel (engines/python/debugger.py): breakpoints
196
+ * by chunk srcname and line, a pause that is an `input()` the worker turns
197
+ * into a `debug` frame, and Stop that still works while paused.
198
+ */
199
+ get canDebug() { return true; }
200
+
201
+ /**
202
+ * One step command for a paused chunk. A COMMAND, not a console line: there
203
+ * is no Browse prompt to type `n` at, and the words R's plane spells as
204
+ * browser() keys mean nothing to Python. The plane has already checked the
205
+ * run is paused and owned by the asking page.
206
+ */
207
+ debugCommand(action, expr) {
208
+ return this.send({ type: "debug_cmd", action, ...(expr != null ? { expr } : {}) });
209
+ }
210
+
211
+ spawnPlan() {
212
+ return {
213
+ bin: this.python,
214
+ args: [path.resolve(this.workerPath)],
215
+ env: { CARMAR_WORKER_MODE: "interactive", CARMAR_PYTHON_KERNEL: this.kernelName },
216
+ };
217
+ }
218
+
219
+ /**
220
+ * A raw console line has no meaning here — worker.py's stdin reader parses
221
+ * commands, and there is no prompt behind it. An answer to `input()` is a
222
+ * COMMAND, read by the same reader while the kernel waits, so it reaches the
223
+ * kernel while a cell is running exactly as R's raw line does.
224
+ */
225
+ console(line) {
226
+ return this.send({ type: "input_reply", value: line });
227
+ }
228
+ }