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,577 @@
1
+ """adapter.py — Jupyter messages in, Beatrix frames out.
2
+
3
+ Ported from `../carmar-python/py/carmapy/kernel_adapter.py` (read 2026-09-14).
4
+ It does not evaluate anything itself; `ipykernel` does that, in its own OS
5
+ process, and this translates.
6
+
7
+ THE RULE THAT COST THREE WRONG MEASUREMENTS OVER THERE
8
+ (`../carmar-python/notes/probes/RESULTS.md` P1):
9
+
10
+ A cell is finished when iopub reports `status: idle` for its parent
11
+ msg_id — NEVER when execute_reply arrives.
12
+
13
+ The channels are independent and the reply routinely precedes the output it
14
+ describes. Closing on the reply drops output the user was supposed to see.
15
+
16
+ What is NEW here, against carmapy's version:
17
+
18
+ * `input_request`. The chunk runs with `allow_stdin=True` and the stdin
19
+ channel is drained in the same loop as iopub and shell, so `input()` in a
20
+ Python chunk raises the page's question instead of hanging the kernel.
21
+ carmapy ran every cell with stdin disallowed.
22
+ * The error frame is emitted in worker.R's SHAPE — `stderr` for the
23
+ traceback text plus a `traceback` frame — because `{"type":"error"}` is a
24
+ frame `src/r-kernel.js` does not handle, so carmapy's version would have
25
+ been dropped by the page it was written for. Measured against the R worker
26
+ on 2026-09-14 (`tmp/probe-r-frames.mjs`).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import json
33
+ import queue
34
+ import sys
35
+ import time
36
+ from typing import Awaitable, Callable
37
+
38
+ from jupyter_client.manager import AsyncKernelManager
39
+
40
+ from bootstrap import BOOTSTRAP, CARMAR_MIME, CONTROL_MIME, REPLY_MIME
41
+
42
+ #: debugger.py's PROMPT. Restated, not imported: debugger.py is kernel-side
43
+ #: source and is never imported into this process. test/host-python-debug
44
+ #: pins the two spellings against each other.
45
+ DEBUG_PROMPT = "\x1bcarmar-debug\x1b"
46
+ #: debugger.py's CarmarDebugAbort, as ipykernel names it on the error message.
47
+ DEBUG_ABORT = "CarmarDebugAbort"
48
+
49
+ Emit = Callable[[dict], Awaitable[None]]
50
+
51
+ COMPLETE_TIMEOUT = 1.5 # matches lib/r-complete.js's 1500 ms
52
+ BUSY_BACKOFF = 10.0 # matches BACKOFF_MS; must always heal, never latch
53
+
54
+
55
+ class PythonSession:
56
+ """One Python session: the notebook's `worker.R` equivalent.
57
+
58
+ Owns exactly one ipykernel process. Not a pool, not a registry — one
59
+ document, one kernel, which is both Jupyter's rule and CarmaR's.
60
+ """
61
+
62
+ def __init__(self, kernel_name: str = "python3", on_input=None, on_debug=None):
63
+ self.kernel_name = kernel_name
64
+ self.km: AsyncKernelManager | None = None
65
+ self.kc = None
66
+ self.busy: bool = False
67
+ self.busy_since: float | None = None
68
+ self._retry_after: float = 0.0
69
+ self._current: str | None = None # wire id of the running cell
70
+ # The worker's cwd, volunteered by the kernel after every cell (see
71
+ # bootstrap.py). Relative paths resolve against it, so an `os.chdir()`
72
+ # in a chunk is known when that cell ends.
73
+ self.cwd: str = ""
74
+ self._reply: dict | None = None # the viewer answer for the drain
75
+ # `on_input(prompt)` is awaited when the kernel asks a question on the
76
+ # stdin channel. The worker turns it into an `input_request` frame; the
77
+ # answer comes back through `answer_input()` from the command reader,
78
+ # which is a DIFFERENT task — that is the whole reason the drain loop
79
+ # must keep yielding rather than block on the answer.
80
+ self._on_input = on_input
81
+ self._input_open = False
82
+ # The debugger's pause is an `input()` whose prompt starts with
83
+ # DEBUG_PROMPT (engines/python/debugger.py). It is NOT a question for
84
+ # the user: `on_debug(payload_json)` turns it into a `debug` frame, and
85
+ # `answer_debug()` — never `answer_input()` — writes the command back.
86
+ self._on_debug = on_debug
87
+ self._debug_open = False
88
+ # Commands that arrived while the paused chunk was BETWEEN prompts — an
89
+ # `eval` still printing when `where` is pressed. R's Browse prompt reads
90
+ # raw console lines, so they queue on its stdin for free; here they
91
+ # queue in this list and the next prompt takes the first one. Cleared
92
+ # with the run: a step can never leak into the next chunk's pause.
93
+ self._debug_pending: list[dict] = []
94
+ #: Set by `_translate` while a chunk runs, read by the worker when it
95
+ #: builds the `done` frame: was this a Stop, and if it failed, why.
96
+ #: Two fields rather than one status, because "interrupted" and "the
97
+ #: shell reply said error" are different facts that arrive on
98
+ #: different channels.
99
+ self.interrupted = False
100
+ self.failure: str | None = None
101
+ #: `{width, height, res, format}` the chunk asked for, so a figure is
102
+ #: sized by what the page requested rather than by a constant.
103
+ self.dims: dict | None = None
104
+ # ONE READER AT A TIME. `_drain_until_idle` consumes the shell, iopub
105
+ # and stdin channels, so two of them running concurrently would steal
106
+ # each other's messages — a `view` sent during a cell would eat that
107
+ # cell's execute_reply and the cell would never finish. The lock makes
108
+ # `view` QUEUE behind a running cell, which is R's behaviour for the
109
+ # same op (`view` is a worker command there too).
110
+ self._chan = asyncio.Lock()
111
+
112
+ # ---------------------------------------------------------------- lifecycle
113
+
114
+ async def start(self) -> dict:
115
+ # IPC, not TCP, and it is a boundary decision rather than a preference.
116
+ # host-plan §2: "an engine never opens a port". A TCP-transport
117
+ # ipykernel opens FIVE listening loopback ports that nothing in Beatrix
118
+ # gates, and ipykernel says so itself — it writes a warning to stderr on
119
+ # every start ("running over TCP without encryption… susceptible to
120
+ # eavesdropping"), which the framer then attributes to whatever chunk
121
+ # happens to be running, so the user's first Python cell prints a
122
+ # security warning about a port they did not ask for. Unix domain
123
+ # sockets in the kernel's own runtime directory have no port, no
124
+ # listener, and file permissions for a gate. Windows has no AF_UNIX for
125
+ # ZMQ, so it keeps TCP on loopback.
126
+ transport = "tcp" if sys.platform == "win32" else "ipc"
127
+ self.km = AsyncKernelManager(kernel_name=self.kernel_name, transport=transport)
128
+ await self.km.start_kernel()
129
+ self.kc = self.km.client()
130
+ self.kc.start_channels()
131
+ await self.kc.wait_for_ready(timeout=60)
132
+ await self._bootstrap()
133
+ return {
134
+ "pid": self.km.provisioner.process.pid if self.km.provisioner else None,
135
+ "python": sys.version.split()[0],
136
+ "cwd": await self._cwd(),
137
+ "executable": sys.executable,
138
+ }
139
+
140
+ async def _bootstrap(self) -> None:
141
+ """Install the DataFrame formatter. Silent to the USER, never to us.
142
+
143
+ carmapy's first version passed emit=None and discarded the result, so
144
+ when the formatter failed to register (pandas 3.0 moved
145
+ DataFrame.__module__) the kernel came up reporting success and quietly
146
+ rendered every DataFrame as raw HTML. A bootstrap that cannot fail
147
+ loudly is a silent failure by construction.
148
+ """
149
+ errors: list[str] = []
150
+
151
+ async def capture(frame):
152
+ if frame.get("type") == "traceback":
153
+ errors.append(frame.get("message", ""))
154
+
155
+ msg_id = self.kc.execute(BOOTSTRAP, silent=True, store_history=False)
156
+ status = await self._drain_until_idle(msg_id, capture, None, timeout=60)
157
+ if status != "ok" or errors:
158
+ raise RuntimeError(
159
+ "the Python engine's bootstrap failed; DataFrames would render "
160
+ "as raw HTML.\n" + "\n".join(errors))
161
+
162
+ async def _cwd(self) -> str:
163
+ out: list[str] = []
164
+
165
+ async def cap(frame):
166
+ if frame.get("type") == "stdout":
167
+ out.append(frame["text"])
168
+
169
+ await self.execute("import os as _os; print(_os.getcwd())", "cwd-probe", cap,
170
+ store_history=False, allow_stdin=False)
171
+ self.cwd = "".join(out).strip()
172
+ return self.cwd
173
+
174
+ async def shutdown(self) -> None:
175
+ if self.kc:
176
+ try:
177
+ self.kc.stop_channels()
178
+ except Exception:
179
+ pass
180
+ if self.km:
181
+ try:
182
+ await self.km.shutdown_kernel(now=True)
183
+ except Exception:
184
+ pass
185
+ # The IPC transport leaves five socket files behind; the connection
186
+ # file too. Nothing else removes them, and a session per document
187
+ # would litter the runtime directory for as long as the machine is
188
+ # up.
189
+ # AsyncKernelManager's is a COROUTINE (`_async_cleanup_resources`),
190
+ # so calling it and discarding the result deletes nothing and warns
191
+ # "coroutine was never awaited" — measured 2026-09-14. Awaited when
192
+ # it is one, called when it is not.
193
+ try:
194
+ maybe = self.km.cleanup_resources()
195
+ if asyncio.iscoroutine(maybe):
196
+ await maybe
197
+ except Exception:
198
+ pass
199
+
200
+ # ---------------------------------------------------------------- execution
201
+
202
+ async def execute(self, code: str, wire_id: str, emit: Emit | None,
203
+ store_history: bool = True, allow_stdin: bool = True,
204
+ srcname: str | None = None, breaks: dict | None = None) -> str:
205
+ """Run one chunk. Emits frames as they arrive; returns the final status.
206
+
207
+ `srcname` ("chunk:<stableId>") names the compiled cell, which is the
208
+ address a breakpoint uses; `breaks` is every breakpoint the page has
209
+ set. Both are handed to the kernel in ONE silent call immediately
210
+ before the chunk, under the same channel lock, so no other cell can be
211
+ compiled in between and take the name.
212
+ """
213
+ async with self._chan:
214
+ self.busy = True
215
+ self.busy_since = time.monotonic()
216
+ self._current = wire_id
217
+ self.interrupted = False
218
+ self.failure = None
219
+ try:
220
+ if srcname:
221
+ spec = json.dumps(json.dumps({"srcname": srcname, "breaks": breaks or {}}))
222
+ prep = self.kc.execute(f"__carmar_debug_prepare({spec})",
223
+ store_history=False, allow_stdin=False)
224
+ await self._drain_until_idle(prep, None, None, timeout=30)
225
+ msg_id = self.kc.execute(code, store_history=store_history,
226
+ allow_stdin=allow_stdin)
227
+ status = await self._drain_until_idle(msg_id, emit, wire_id)
228
+ # The shell reply says "error" for a Stop too, so the two facts
229
+ # are reconciled here and the caller gets R's vocabulary.
230
+ # BOTH conditions: a Stop pressed a millisecond before a chunk
231
+ # would have finished anyway leaves `interrupted` set against a
232
+ # run that succeeded, and R reports that run `ok`.
233
+ if self.interrupted and status != "ok":
234
+ return "interrupted"
235
+ return status
236
+ finally:
237
+ self.busy = False
238
+ self.busy_since = None
239
+ self._current = None
240
+ self._input_open = False
241
+ self._debug_open = False
242
+ self._debug_pending = []
243
+
244
+ async def request(self, op: str, spec: dict) -> dict:
245
+ """A request answered by the kernel — `view`, `colstats`, `env`, `rm`.
246
+
247
+ It runs in the evaluating session because that is where the objects
248
+ are, which is exactly why R routes `view`/`colstats` to `worker.R`. It
249
+ therefore QUEUES behind a running cell, which is R's behaviour too.
250
+
251
+ The wire never carries code. `spec` is JSON, passed as ONE argument to
252
+ a function the bootstrap installed; the kernel-side half parses it. A
253
+ composed `__carmapy_view(df, sort='...')` string would be the browser
254
+ writing Python, which is the door this design does not have.
255
+ """
256
+ async with self._chan:
257
+ self._reply = None
258
+ payload = json.dumps(json.dumps(spec, allow_nan=False))
259
+ code = f"__carmapy_reply({op!r}, {payload})"
260
+ msg_id = self.kc.execute(code, store_history=False, allow_stdin=False)
261
+ await self._drain_until_idle(msg_id, None, None, timeout=300)
262
+ reply, self._reply = self._reply, None
263
+ if reply is None:
264
+ return {"error": "the session returned no answer"}
265
+ return reply
266
+
267
+ async def _drain_until_idle(self, msg_id: str, emit: Emit | None,
268
+ wire_id: str | None, timeout: float = 86400) -> str:
269
+ """Consume iopub until idle for THIS parent, and the shell reply.
270
+
271
+ Both conditions. See the module docstring — this is the P1 rule and
272
+ the single most important line of behaviour in the file.
273
+ """
274
+ status = "ok"
275
+ idle = False
276
+ reply = False
277
+ deadline = time.monotonic() + timeout
278
+
279
+ while time.monotonic() < deadline and not (idle and reply):
280
+ got = False
281
+ try:
282
+ m = await self.kc.get_iopub_msg(timeout=0.02)
283
+ got = True
284
+ if m.get("parent_header", {}).get("msg_id") == msg_id:
285
+ frames = self._translate(m, wire_id)
286
+ if emit:
287
+ for f in frames:
288
+ await emit(f)
289
+ if (m["header"]["msg_type"] == "status"
290
+ and m["content"]["execution_state"] == "idle"):
291
+ idle = True
292
+ except (queue.Empty, asyncio.TimeoutError):
293
+ pass
294
+ except Exception:
295
+ pass
296
+ try:
297
+ r = await self.kc.get_shell_msg(timeout=0.01)
298
+ got = True
299
+ if r.get("parent_header", {}).get("msg_id") == msg_id:
300
+ status = r["content"].get("status", "ok")
301
+ reply = True
302
+ except (queue.Empty, asyncio.TimeoutError):
303
+ pass
304
+ except Exception:
305
+ pass
306
+ # The stdin channel, drained in the SAME loop. A question asked
307
+ # while a cell runs is answered by another task (`answer_input`),
308
+ # so this must never block waiting for it.
309
+ if self._on_input is not None and not self._input_open and not self._debug_open:
310
+ try:
311
+ s = await self.kc.get_stdin_msg(timeout=0.01)
312
+ got = True
313
+ asked = s["header"]["msg_type"] == "input_request" \
314
+ and s.get("parent_header", {}).get("msg_id") == msg_id
315
+ prompt = s["content"].get("prompt", "") if asked else ""
316
+ if asked and prompt.startswith(DEBUG_PROMPT) and self._on_debug is not None:
317
+ self._debug_open = True
318
+ await self._on_debug(prompt[len(DEBUG_PROMPT):], wire_id)
319
+ if self._debug_pending:
320
+ self._deliver_debug(self._debug_pending.pop(0))
321
+ elif asked:
322
+ self._input_open = True
323
+ await self._on_input(prompt, wire_id)
324
+ except (queue.Empty, asyncio.TimeoutError):
325
+ pass
326
+ except Exception:
327
+ pass
328
+ if not got:
329
+ await asyncio.sleep(0) # yield to the event loop
330
+ return status
331
+
332
+ @property
333
+ def input_open(self) -> bool:
334
+ return self._input_open
335
+
336
+ @property
337
+ def current(self) -> str | None:
338
+ """The wire id of the cell running right now, or None."""
339
+ return self._current
340
+
341
+ @property
342
+ def debug_open(self) -> bool:
343
+ """Is the running chunk paused in the debugger right now?"""
344
+ return self._debug_open
345
+
346
+ def answer_debug(self, command: dict) -> bool:
347
+ """Deliver one debugger command to the paused chunk — now if it is at
348
+ its prompt, at its next prompt if it is between two. False when no
349
+ chunk is running: a step for a run that already finished does nothing.
350
+ """
351
+ if self.kc is None or self._current is None:
352
+ return False
353
+ if not self._debug_open:
354
+ self._debug_pending.append(command)
355
+ return True
356
+ self._deliver_debug(command)
357
+ return True
358
+
359
+ def _deliver_debug(self, command: dict) -> None:
360
+ self.kc.input(json.dumps(command, separators=(",", ":")))
361
+ self._debug_open = False
362
+
363
+ def answer_input(self, value: str) -> bool:
364
+ """Deliver one line to a waiting `input()`. False when nothing asked."""
365
+ if not self._input_open or self.kc is None:
366
+ return False
367
+ self.kc.input(value)
368
+ self._input_open = False
369
+ return True
370
+
371
+ def _translate(self, m: dict, wire_id: str | None) -> list[dict]:
372
+ """One Jupyter message -> zero or more Beatrix frames."""
373
+ t = m["header"]["msg_type"]
374
+ c = m["content"]
375
+
376
+ if t == "stream":
377
+ kind = "stderr" if c.get("name") == "stderr" else "stdout"
378
+ text = c.get("text", "")
379
+ # ipykernel 7 reports a Stop TWO ways and the choice is not ours:
380
+ # an `error` message with ename KeyboardInterrupt when the signal
381
+ # lands inside the user's frame, and a bare "\nKeyboardInterrupt"
382
+ # written to stderr when it lands while the previous one is still
383
+ # unwinding (measured 2026-09-14 — the interrupt ladder re-sends at
384
+ # +60 ms, so the second shape is the common one). R prints NOTHING
385
+ # when a chunk is interrupted, so neither does this: the word alone,
386
+ # while a Stop this worker sent is outstanding, is the acknowledgement
387
+ # and not output the user wrote.
388
+ if kind == "stderr" and self.interrupted \
389
+ and set(text.split()) <= {"KeyboardInterrupt"}:
390
+ return []
391
+ # The page accumulates and joins on "\n"; keep R's line semantics.
392
+ return [{"type": kind, "id": wire_id, "text": text.rstrip("\n")}]
393
+
394
+ if t == "error":
395
+ ename, evalue = c.get("ename") or "Error", c.get("evalue")
396
+ headline = f"{ename}: {evalue}" if evalue else str(ename)
397
+ # A KeyboardInterrupt is STOP, not a failure. R's worker ends an
398
+ # interrupted chunk with `done {status:"interrupted"}` and prints
399
+ # no traceback (measured against the R worker, 2026-09-14), and a
400
+ # page that showed a stack trace every time someone pressed Stop
401
+ # would be reporting a bug that does not exist.
402
+ if ename == "KeyboardInterrupt":
403
+ self.interrupted = True
404
+ return []
405
+ # The debugger's Stop: R reports it as `interrupted` with the
406
+ # message "Stopped from the debugger" (worker.R carmar_abort_cell),
407
+ # and so does this — no traceback for a button the user pressed.
408
+ if ename == DEBUG_ABORT:
409
+ self.interrupted = True
410
+ self.failure = "Stopped from the debugger"
411
+ return []
412
+ self.failure = headline
413
+ tb = "\n".join(c.get("traceback", []))
414
+ # worker.R's shape, measured: the text of a failure reaches the
415
+ # page as `stderr`, and ONE `traceback` frame carries the headline
416
+ # and the line. `{"type":"error"}` is not in src/r-kernel.js's
417
+ # switch and would be silently dropped.
418
+ return [
419
+ {"type": "stderr", "id": wire_id, "text": tb or headline},
420
+ {"type": "traceback", "id": wire_id, "message": headline,
421
+ "line": _error_line(c), "call": ename, "frames": []},
422
+ ]
423
+
424
+ if t in ("execute_result", "display_data"):
425
+ return self._from_bundle(c.get("data", {}) or {}, wire_id,
426
+ c.get("metadata") or {})
427
+
428
+ return []
429
+
430
+ def _from_bundle(self, data: dict, wire_id: str | None, meta: dict | None = None) -> list[dict]:
431
+ """The MIME bundle, richest representation first.
432
+
433
+ Order matters: a DataFrame also carries text/html and text/plain, and
434
+ emitting all three would paint the same value three times.
435
+ """
436
+ # The kernel's two private MIMEs are read FIRST and produce no frame.
437
+ # A viewer answer belongs to the caller waiting on it, not to the
438
+ # notebook's output pane, and the cwd is state the worker records.
439
+ if CONTROL_MIME in data:
440
+ payload = data[CONTROL_MIME]
441
+ if isinstance(payload, str):
442
+ payload = json.loads(payload)
443
+ if isinstance(payload.get("cwd"), str):
444
+ self.cwd = payload["cwd"]
445
+ return []
446
+
447
+ if REPLY_MIME in data:
448
+ payload = data[REPLY_MIME]
449
+ self._reply = json.loads(payload) if isinstance(payload, str) else payload
450
+ return []
451
+
452
+ if CARMAR_MIME in data:
453
+ payload = data[CARMAR_MIME]
454
+ if isinstance(payload, str):
455
+ payload = json.loads(payload)
456
+ return [{"type": "dataframe", "id": wire_id, "source": None, **payload}]
457
+
458
+ # The page sizes a figure by pixels ÷ (res ÷ 96), so `res` is not
459
+ # optional — a 1500px plot at 300 dpi is a five-inch figure. Three
460
+ # sources, best first: the message's own metadata, the dims the chunk
461
+ # asked for, then the page's defaults.
462
+ png_meta = (meta or {}).get("image/png") or {}
463
+ dims = self.dims or {}
464
+
465
+ def _size(key, fallback):
466
+ for src in (png_meta, dims):
467
+ v = src.get(key)
468
+ if isinstance(v, (int, float)) and v > 0:
469
+ return v
470
+ return fallback
471
+
472
+ if "image/png" in data:
473
+ return [{"type": "plot", "id": wire_id, "mime": "image/png",
474
+ "width": _size("width", 672), "height": _size("height", 480),
475
+ "res": _size("res", 96),
476
+ "data": data["image/png"].replace("\n", "")}]
477
+
478
+ if "image/svg+xml" in data:
479
+ return [{"type": "plot", "id": wire_id, "mime": "image/svg+xml",
480
+ "width": _size("width", 672), "height": _size("height", 480),
481
+ "res": _size("res", 96),
482
+ "data": data["image/svg+xml"]}]
483
+
484
+ if "text/html" in data:
485
+ return [{"type": "widget", "id": wire_id, "kind": "html",
486
+ "class": "html", "html": data["text/html"]}]
487
+
488
+ if "text/plain" in data:
489
+ return [{"type": "stdout", "id": wire_id, "text": data["text/plain"]}]
490
+
491
+ return []
492
+
493
+ # ---------------------------------------------------------------- control
494
+
495
+ async def interrupt(self) -> None:
496
+ """Stop.
497
+
498
+ `interrupt_mode` is `signal` for the stock python3 kernelspec, so this
499
+ is a SIGINT to the kernel process — which jupyter_client started in its
500
+ own session (measured 2026-09-14: kernel sid == kernel pid), so the
501
+ host's group signal to this worker can never reach it. That is exactly
502
+ why the worker catches SIGINT and calls this.
503
+ """
504
+ if self.km is not None:
505
+ await self.km.interrupt_kernel()
506
+
507
+ async def force_stop(self) -> None:
508
+ """The second button. A C extension that neither releases the GIL nor
509
+ checks signals cannot be interrupted by any message — only killed."""
510
+ if self.km is not None:
511
+ await self.km.shutdown_kernel(now=True)
512
+
513
+ # ---------------------------------------------------------------- intelligence
514
+
515
+ async def complete(self, line: str, cursor: int) -> dict | None:
516
+ """LIVE completion — the tier a static analyser cannot serve.
517
+
518
+ Returns None rather than waiting when the kernel is busy. That guard is
519
+ MANDATORY, not an optimisation: carmapy's P3 measured
520
+ `complete_request` getting no answer in 3.0 s while a cell ran, because
521
+ it rides the shell channel and is served in order. Without this, every
522
+ keystroke pause during a long run buys a hang.
523
+
524
+ The backoff always heals on its own and never latches until a reload —
525
+ R learned that the hard way when a four-second timeout marked
526
+ completion unsupported for the rest of the page.
527
+ """
528
+ # `_chan.locked()` as well as `busy`: a `view` on a million-row frame
529
+ # holds the channel without being a running CELL, and a completion that
530
+ # read the shell channel underneath it would steal the viewer's reply.
531
+ if self.busy or self._chan.locked() or time.monotonic() < self._retry_after:
532
+ return None
533
+ msg_id = self.kc.complete(line, cursor)
534
+ deadline = time.monotonic() + COMPLETE_TIMEOUT
535
+ while time.monotonic() < deadline:
536
+ try:
537
+ r = await self.kc.get_shell_msg(timeout=0.05)
538
+ except (queue.Empty, asyncio.TimeoutError):
539
+ await asyncio.sleep(0)
540
+ continue
541
+ except Exception:
542
+ break
543
+ if r.get("parent_header", {}).get("msg_id") == msg_id:
544
+ c = r["content"]
545
+ self._retry_after = 0.0
546
+ return {"start": c.get("cursor_start", cursor),
547
+ "end": c.get("cursor_end", cursor),
548
+ "matches": c.get("matches", [])}
549
+ self._retry_after = time.monotonic() + BUSY_BACKOFF
550
+ return None
551
+
552
+
553
+ def _error_line(content: dict):
554
+ """The 1-based line inside THE CHUNK THAT RAN, or None.
555
+
556
+ ipykernel's traceback frames for a cell carry the line as
557
+ `Cell In[3], line 4`, or as a bare `----> 4` arrow. Two things the first
558
+ version missed, both measured 2026-09-14 against IPython 9.11:
559
+
560
+ * The text is ANSI-COLOURED — `Cell`, `In[3]` and `, line 4` each sit in
561
+ their own escape sequence — so neither pattern matched anything and
562
+ every Python traceback reached the page with `line: null`. The escapes
563
+ are stripped before reading.
564
+ * The FIRST cell frame is the chunk that ran; later ones are deeper
565
+ calls, possibly into a function another chunk defined. R's `line` is
566
+ the line within the running chunk (worker.R `at_line`), so it is the
567
+ first match, not the last.
568
+ """
569
+ import re
570
+ text = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", "\n".join(content.get("traceback", [])))
571
+ hits = re.findall(r"[Cc]ell In\[\d+\],\s*line\s*(\d+)", text)
572
+ if not hits:
573
+ hits = re.findall(r"-+>\s*(\d+)", text)
574
+ try:
575
+ return int(hits[0]) if hits else None
576
+ except Exception:
577
+ return None