@oh-my-pi/pi-coding-agent 14.9.5 → 14.9.8

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 (54) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/package.json +7 -7
  3. package/scripts/generate-template.ts +4 -3
  4. package/src/cli/setup-cli.ts +14 -161
  5. package/src/cli/stats-cli.ts +56 -2
  6. package/src/cli.ts +0 -1
  7. package/src/config/settings-schema.ts +0 -10
  8. package/src/eval/eval.lark +30 -10
  9. package/src/eval/js/context-manager.ts +334 -564
  10. package/src/eval/js/shared/helpers.ts +237 -0
  11. package/src/eval/js/shared/indirect-eval.ts +30 -0
  12. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  13. package/src/eval/js/shared/runtime.ts +168 -0
  14. package/src/eval/js/shared/types.ts +18 -0
  15. package/src/eval/js/tool-bridge.ts +2 -4
  16. package/src/eval/js/worker-core.ts +146 -0
  17. package/src/eval/js/worker-entry.ts +24 -0
  18. package/src/eval/js/worker-protocol.ts +41 -0
  19. package/src/eval/parse.ts +218 -49
  20. package/src/eval/py/display.ts +71 -0
  21. package/src/eval/py/executor.ts +74 -89
  22. package/src/eval/py/index.ts +1 -2
  23. package/src/eval/py/kernel.ts +472 -900
  24. package/src/eval/py/prelude.py +95 -7
  25. package/src/eval/py/runner.py +879 -0
  26. package/src/eval/py/runtime.ts +3 -16
  27. package/src/eval/py/tool-bridge.ts +137 -0
  28. package/src/export/html/index.ts +5 -2
  29. package/src/export/html/template.generated.ts +1 -1
  30. package/src/export/html/template.js +93 -5
  31. package/src/export/html/template.macro.ts +4 -3
  32. package/src/internal-urls/docs-index.generated.ts +3 -3
  33. package/src/modes/components/read-tool-group.ts +9 -0
  34. package/src/modes/controllers/command-controller.ts +0 -23
  35. package/src/prompts/tools/eval.md +14 -27
  36. package/src/prompts/tools/read.md +1 -0
  37. package/src/session/agent-session.ts +0 -1
  38. package/src/session/history-storage.ts +77 -19
  39. package/src/tools/browser/tab-protocol.ts +4 -0
  40. package/src/tools/browser/tab-supervisor.ts +86 -5
  41. package/src/tools/browser/tab-worker.ts +104 -58
  42. package/src/tools/conflict-detect.ts +661 -0
  43. package/src/tools/eval.ts +1 -1
  44. package/src/tools/index.ts +6 -0
  45. package/src/tools/path-utils.ts +1 -1
  46. package/src/tools/read.ts +130 -0
  47. package/src/tools/write.ts +204 -0
  48. package/src/web/search/index.ts +6 -4
  49. package/src/cli/jupyter-cli.ts +0 -106
  50. package/src/commands/jupyter.ts +0 -32
  51. package/src/eval/py/cancellation.ts +0 -28
  52. package/src/eval/py/gateway-coordinator.ts +0 -424
  53. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
  54. /package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -0
@@ -0,0 +1,879 @@
1
+ """OMP Python runner — subprocess wrapper used by the coding-agent host.
2
+
3
+ NDJSON protocol over stdin/stdout. Host writes one JSON object per line;
4
+ wrapper writes typed frames back.
5
+
6
+ Host -> wrapper:
7
+ {"id": str, "code": str, "silent": bool?, "storeHistory": bool?}
8
+ {"type": "exit"} # graceful shutdown
9
+
10
+ Wrapper -> host:
11
+ {"type": "started", "id": ...}
12
+ {"type": "stdout", "id": ..., "data": str}
13
+ {"type": "stderr", "id": ..., "data": str}
14
+ {"type": "display", "id": ..., "bundle": {<mime>: <value>}}
15
+ {"type": "result", "id": ..., "bundle": {<mime>: <value>}}
16
+ {"type": "error", "id": ..., "ename": str, "evalue": str, "traceback": [str]}
17
+ {"type": "done", "id": ..., "status": "ok"|"error",
18
+ "executionCount": int, "cancelled": bool}
19
+
20
+ The runner is intentionally self-contained: no third-party imports, no IPython.
21
+ Magics are translated by a small line-scanner before AST parsing; rich display
22
+ falls back through `_repr_*_` methods so pandas/PIL/plotly etc. still render
23
+ when installed.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import ast
29
+ import base64
30
+ import builtins
31
+ import io
32
+ import json
33
+ import os
34
+ import re
35
+ import runpy
36
+ import shlex
37
+ import signal
38
+ import subprocess
39
+ import sys
40
+ import threading
41
+ import time
42
+ import traceback
43
+ from pathlib import Path
44
+ from typing import Any, Callable
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Frame writer
48
+ # ---------------------------------------------------------------------------
49
+
50
+ _RAW_STDOUT = sys.__stdout__
51
+ _RAW_STDERR = sys.__stderr__
52
+ _OUT_LOCK = threading.Lock()
53
+
54
+
55
+ def _json_default(o: Any) -> Any:
56
+ try:
57
+ return repr(o)
58
+ except Exception:
59
+ return f"<unrepr {type(o).__name__}>"
60
+
61
+
62
+ def _emit(frame: dict) -> None:
63
+ """Serialize a frame and write it to the host as a single NDJSON line."""
64
+ line = json.dumps(frame, ensure_ascii=False, default=_json_default)
65
+ with _OUT_LOCK:
66
+ _RAW_STDOUT.write(line)
67
+ _RAW_STDOUT.write("\n")
68
+ _RAW_STDOUT.flush()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # User stdout/stderr proxies
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class _StreamProxy(io.TextIOBase):
77
+ """Emit each ``write()`` as a typed frame tied to the current request."""
78
+
79
+ def __init__(self, kind: str) -> None:
80
+ super().__init__()
81
+ self._kind = kind
82
+
83
+ def writable(self) -> bool: # noqa: D401 - protocol method
84
+ return True
85
+
86
+ def isatty(self) -> bool: # noqa: D401 - protocol method
87
+ return False
88
+
89
+ def write(self, data: Any) -> int: # type: ignore[override]
90
+ if not isinstance(data, str):
91
+ data = str(data)
92
+ if not data:
93
+ return 0
94
+ rid = _STATE.current_id
95
+ if rid is None:
96
+ _RAW_STDERR.write(data)
97
+ _RAW_STDERR.flush()
98
+ return len(data)
99
+ _emit({"type": self._kind, "id": rid, "data": data})
100
+ return len(data)
101
+
102
+ def flush(self) -> None: # noqa: D401 - protocol method
103
+ return None
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Runner state
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ class _RunnerState:
112
+ def __init__(self) -> None:
113
+ self.current_id: str | None = None
114
+ self.execution_count: int = 0
115
+ self.cancel_requested: bool = False
116
+ # User globals — kept across requests when running in session mode.
117
+ self.user_ns: dict[str, Any] = {
118
+ "__name__": "__main__",
119
+ "__doc__": None,
120
+ "__builtins__": builtins,
121
+ }
122
+ self.last_install_marker: int = 0
123
+
124
+
125
+ _STATE = _RunnerState()
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Magic source transformer
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ _MAGIC_LINE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<name>[A-Za-z_][A-Za-z_0-9]*)(?:[ \t]+(?P<args>.*))?$")
134
+ _ASSIGN_LINE_RE = re.compile(
135
+ r"^(?P<indent>[ \t]*)(?P<lhs>[A-Za-z_][A-Za-z_0-9.\[\], ]*?)\s*=\s*(?P<rhs>.+)$"
136
+ )
137
+
138
+
139
+ def _fold_continuations(lines: list[str], start: int) -> tuple[str, int]:
140
+ """Fold trailing backslash continuations starting at ``start``. Returns
141
+ ``(folded_text, lines_consumed)``."""
142
+ parts: list[str] = []
143
+ i = start
144
+ while i < len(lines):
145
+ line = lines[i]
146
+ if line.endswith("\\"):
147
+ parts.append(line[:-1])
148
+ i += 1
149
+ continue
150
+ parts.append(line)
151
+ i += 1
152
+ break
153
+ return ("".join(parts), i - start)
154
+
155
+
156
+ def _quote_arg(text: str) -> str:
157
+ """Return a Python string literal that round-trips ``text`` exactly."""
158
+ return json.dumps(text, ensure_ascii=False)
159
+
160
+
161
+ def transform_cell(source: str) -> str:
162
+ """Translate IPython-style magics + shell escapes into plain Python.
163
+
164
+ Rules
165
+ -----
166
+ * ``%name args`` -> ``__omp_magic("name", "args")``
167
+ * ``var = %name args`` -> ``var = __omp_magic("name", "args")``
168
+ * ``!cmd`` -> ``__omp_shell("cmd")``
169
+ * ``var = !cmd`` -> ``var = __omp_shell("cmd")``
170
+ * ``%%name args\\n<body>`` -> ``__omp_magic_cell("name", "args", "<body>")``
171
+ (cell magic must be the first non-whitespace token of a top-level line and
172
+ consumes the remainder of the cell)
173
+
174
+ Lines inside strings or comments are left alone — we operate on the raw
175
+ text before parsing, but the scanner only fires on the first token of each
176
+ physical line and never touches the body of triple-quoted strings because
177
+ those bodies are never first tokens themselves.
178
+ """
179
+
180
+ if "%" not in source and "!" not in source:
181
+ return source
182
+
183
+ lines = source.splitlines()
184
+ out: list[str] = []
185
+ i = 0
186
+ while i < len(lines):
187
+ line = lines[i]
188
+ stripped = line.lstrip()
189
+ indent = line[: len(line) - len(stripped)]
190
+
191
+ # Cell magic — consumes from here to EOF.
192
+ if stripped.startswith("%%"):
193
+ head, _ = _split_magic_head(stripped[2:])
194
+ name, args = head
195
+ body_lines = lines[i + 1 :]
196
+ body = "\n".join(body_lines)
197
+ out.append(
198
+ f"{indent}__omp_magic_cell({_quote_arg(name)}, {_quote_arg(args)}, {_quote_arg(body)})"
199
+ )
200
+ return "\n".join(out)
201
+
202
+ # Line magic / shell at start of line.
203
+ if stripped.startswith("%") and not stripped.startswith("%%"):
204
+ folded, consumed = _fold_continuations(lines, i)
205
+ stripped_folded = folded.lstrip()
206
+ indent = folded[: len(folded) - len(stripped_folded)]
207
+ head, _ = _split_magic_head(stripped_folded[1:])
208
+ name, args = head
209
+ out.append(f"{indent}__omp_magic({_quote_arg(name)}, {_quote_arg(args)})")
210
+ i += consumed
211
+ continue
212
+
213
+ if stripped.startswith("!"):
214
+ folded, consumed = _fold_continuations(lines, i)
215
+ stripped_folded = folded.lstrip()
216
+ indent = folded[: len(folded) - len(stripped_folded)]
217
+ cmd = stripped_folded[1:].strip()
218
+ out.append(f"{indent}__omp_shell({_quote_arg(cmd)})")
219
+ i += consumed
220
+ continue
221
+
222
+ # Assignment forms: var = %magic / var = !cmd
223
+ m = _ASSIGN_LINE_RE.match(line)
224
+ if m:
225
+ rhs = m.group("rhs").strip()
226
+ if rhs.startswith("!"):
227
+ cmd = rhs[1:].strip()
228
+ out.append(f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_shell({_quote_arg(cmd)})")
229
+ i += 1
230
+ continue
231
+ if rhs.startswith("%") and not rhs.startswith("%%"):
232
+ head, _ = _split_magic_head(rhs[1:])
233
+ name, args = head
234
+ out.append(
235
+ f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_magic({_quote_arg(name)}, {_quote_arg(args)})"
236
+ )
237
+ i += 1
238
+ continue
239
+
240
+ out.append(line)
241
+ i += 1
242
+
243
+ return "\n".join(out)
244
+
245
+
246
+ def _split_magic_head(text: str) -> tuple[tuple[str, str], str]:
247
+ """Split ``"name rest"`` into ``("name", "rest")``."""
248
+ text = text.lstrip()
249
+ if not text:
250
+ return ("", ""), ""
251
+ m = re.match(r"([A-Za-z_][A-Za-z_0-9]*)(?:\s+(.*))?$", text)
252
+ if not m:
253
+ return ("", text), ""
254
+ return (m.group(1), (m.group(2) or "").rstrip()), ""
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Magic registry
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {}
263
+ _CELL_MAGICS: dict[str, Callable[[str, str], Any]] = {}
264
+
265
+
266
+ def line_magic(name: str) -> Callable[[Callable[[str], Any]], Callable[[str], Any]]:
267
+ def decorator(fn: Callable[[str], Any]) -> Callable[[str], Any]:
268
+ _LINE_MAGICS[name] = fn
269
+ return fn
270
+
271
+ return decorator
272
+
273
+
274
+ def cell_magic(name: str) -> Callable[[Callable[[str, str], Any]], Callable[[str, str], Any]]:
275
+ def decorator(fn: Callable[[str, str], Any]) -> Callable[[str, str], Any]:
276
+ _CELL_MAGICS[name] = fn
277
+ return fn
278
+
279
+ return decorator
280
+
281
+
282
+ def _emit_status(op: str, **data: Any) -> None:
283
+ bundle = {"application/x-omp-status": {"op": op, **data}}
284
+ rid = _STATE.current_id
285
+ if rid is None:
286
+ return
287
+ _emit({"type": "display", "id": rid, "bundle": bundle})
288
+
289
+
290
+ @line_magic("pip")
291
+ def _magic_pip(args: str) -> None:
292
+ argv = shlex.split(args) if args else ["--help"]
293
+ cmd = [sys.executable, "-m", "pip", *argv]
294
+ proc = subprocess.Popen(
295
+ cmd,
296
+ stdout=subprocess.PIPE,
297
+ stderr=subprocess.STDOUT,
298
+ text=True,
299
+ bufsize=1,
300
+ )
301
+ installed_packages: list[str] = []
302
+ assert proc.stdout is not None
303
+ for raw_line in proc.stdout:
304
+ sys.stdout.write(raw_line)
305
+ m = re.search(r"Successfully installed\s+(.+)$", raw_line)
306
+ if m:
307
+ for token in m.group(1).split():
308
+ # Token is name-version; drop the version suffix.
309
+ pkg = token.rsplit("-", 1)[0]
310
+ installed_packages.append(pkg.replace("_", "-"))
311
+ proc.wait()
312
+ if installed_packages:
313
+ import importlib
314
+
315
+ importlib.invalidate_caches()
316
+ prefixes = {pkg.lower().replace("-", "_") for pkg in installed_packages}
317
+ for mod_name in list(sys.modules):
318
+ head = mod_name.split(".", 1)[0].lower()
319
+ if head in prefixes:
320
+ sys.modules.pop(mod_name, None)
321
+ _emit_status("pip", args=args, installed=installed_packages, exit_code=proc.returncode)
322
+
323
+
324
+ @line_magic("cd")
325
+ def _magic_cd(args: str) -> str:
326
+ path = os.path.expanduser(args.strip()) or os.path.expanduser("~")
327
+ os.chdir(path)
328
+ cwd = os.getcwd()
329
+ _emit_status("cd", path=cwd)
330
+ return cwd
331
+
332
+
333
+ @line_magic("pwd")
334
+ def _magic_pwd(_args: str) -> str:
335
+ cwd = os.getcwd()
336
+ _emit_status("pwd", path=cwd)
337
+ return cwd
338
+
339
+
340
+ @line_magic("ls")
341
+ def _magic_ls(args: str) -> list[str]:
342
+ target = os.path.expanduser(args.strip()) or "."
343
+ entries = sorted(os.listdir(target))
344
+ _emit_status("ls", path=os.path.abspath(target), count=len(entries))
345
+ return entries
346
+
347
+
348
+ @line_magic("env")
349
+ def _magic_env(args: str) -> Any:
350
+ args = args.strip()
351
+ if not args:
352
+ return dict(sorted(os.environ.items()))
353
+ if "=" in args:
354
+ key, value = args.split("=", 1)
355
+ os.environ[key.strip()] = value.strip()
356
+ return value.strip()
357
+ return os.environ.get(args)
358
+
359
+
360
+ @line_magic("set_env")
361
+ def _magic_set_env(args: str) -> str:
362
+ parts = args.split(None, 1)
363
+ if len(parts) != 2:
364
+ raise ValueError("Usage: %set_env KEY VALUE")
365
+ key, value = parts
366
+ os.environ[key] = value
367
+ return value
368
+
369
+
370
+ @line_magic("time")
371
+ def _magic_time(args: str) -> Any:
372
+ start = time.perf_counter()
373
+ result = eval(args, _STATE.user_ns)
374
+ elapsed = time.perf_counter() - start
375
+ sys.stdout.write(f"Wall time: {elapsed * 1000:.2f} ms\n")
376
+ _emit_status("time", elapsed_ms=round(elapsed * 1000, 3))
377
+ return result
378
+
379
+
380
+ @line_magic("timeit")
381
+ def _magic_timeit(args: str) -> None:
382
+ import timeit as _timeit
383
+
384
+ timer = _timeit.Timer(stmt=args, globals=_STATE.user_ns)
385
+ iters, total = timer.autorange()
386
+ per = total / iters
387
+ sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
388
+ _emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
389
+
390
+
391
+ @line_magic("who")
392
+ def _magic_who(_args: str) -> list[str]:
393
+ names = sorted(
394
+ name
395
+ for name, value in _STATE.user_ns.items()
396
+ if not name.startswith("_") and not callable(value) or hasattr(value, "__class__")
397
+ )
398
+ return [n for n in names if not n.startswith("__")]
399
+
400
+
401
+ @line_magic("whos")
402
+ def _magic_whos(_args: str) -> list[tuple[str, str]]:
403
+ rows = []
404
+ for name in sorted(_STATE.user_ns):
405
+ if name.startswith("__"):
406
+ continue
407
+ value = _STATE.user_ns[name]
408
+ rows.append((name, type(value).__name__))
409
+ return rows
410
+
411
+
412
+ @line_magic("reset")
413
+ def _magic_reset(_args: str) -> None:
414
+ _STATE.user_ns.clear()
415
+ _STATE.user_ns.update({"__name__": "__main__", "__doc__": None, "__builtins__": builtins})
416
+ _install_builtins(_STATE.user_ns)
417
+ _emit_status("reset")
418
+
419
+
420
+ @line_magic("load")
421
+ def _magic_load(args: str) -> None:
422
+ path = Path(os.path.expanduser(args.strip()))
423
+ source = path.read_text(encoding="utf-8")
424
+ _emit({"type": "display", "id": _STATE.current_id, "bundle": {"text/plain": source}})
425
+ _exec_source(source, _STATE.user_ns)
426
+
427
+
428
+ @line_magic("run")
429
+ def _magic_run(args: str) -> None:
430
+ parts = shlex.split(args) if args else []
431
+ if not parts:
432
+ raise ValueError("Usage: %run <path>")
433
+ target = os.path.expanduser(parts[0])
434
+ saved_argv = sys.argv
435
+ try:
436
+ sys.argv = [target, *parts[1:]]
437
+ result_ns = runpy.run_path(target, run_name="__main__")
438
+ finally:
439
+ sys.argv = saved_argv
440
+ for name, value in result_ns.items():
441
+ if name.startswith("__"):
442
+ continue
443
+ _STATE.user_ns[name] = value
444
+
445
+
446
+ @cell_magic("bash")
447
+ def _magic_cell_bash(args: str, body: str) -> int:
448
+ return _run_shell_body(body, shell_arg="/bin/bash")
449
+
450
+
451
+ @cell_magic("sh")
452
+ def _magic_cell_sh(args: str, body: str) -> int:
453
+ return _run_shell_body(body, shell_arg="/bin/sh")
454
+
455
+
456
+ @cell_magic("capture")
457
+ def _magic_cell_capture(args: str, body: str) -> str:
458
+ """Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
459
+ captured = io.StringIO()
460
+ saved_stdout, saved_stderr = sys.stdout, sys.stderr
461
+ sys.stdout = sys.stderr = captured
462
+ try:
463
+ _exec_source(body, _STATE.user_ns)
464
+ finally:
465
+ sys.stdout, sys.stderr = saved_stdout, saved_stderr
466
+ text = captured.getvalue()
467
+ name = args.strip()
468
+ if name:
469
+ _STATE.user_ns[name] = text
470
+ return text
471
+
472
+
473
+ @cell_magic("timeit")
474
+ def _magic_cell_timeit(args: str, body: str) -> None:
475
+ import timeit as _timeit
476
+
477
+ timer = _timeit.Timer(stmt=body, globals=_STATE.user_ns)
478
+ iters, total = timer.autorange()
479
+ per = total / iters
480
+ sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
481
+ _emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
482
+
483
+
484
+ @cell_magic("writefile")
485
+ def _magic_cell_writefile(args: str, body: str) -> str:
486
+ path = Path(os.path.expanduser(args.strip()))
487
+ path.parent.mkdir(parents=True, exist_ok=True)
488
+ path.write_text(body, encoding="utf-8")
489
+ _emit_status("writefile", path=str(path), bytes=len(body))
490
+ return str(path)
491
+
492
+
493
+ def _run_shell_body(body: str, *, shell_arg: str) -> int:
494
+ proc = subprocess.Popen(
495
+ [shell_arg, "-c", body],
496
+ stdout=subprocess.PIPE,
497
+ stderr=subprocess.STDOUT,
498
+ text=True,
499
+ bufsize=1,
500
+ )
501
+ assert proc.stdout is not None
502
+ for raw_line in proc.stdout:
503
+ sys.stdout.write(raw_line)
504
+ proc.wait()
505
+ return proc.returncode
506
+
507
+
508
+ def __omp_magic(name: str, args: str) -> Any:
509
+ fn = _LINE_MAGICS.get(name)
510
+ if fn is None:
511
+ raise NameError(f"UsageError: Line magic function '%{name}' not found.")
512
+ return fn(args)
513
+
514
+
515
+ def __omp_magic_cell(name: str, args: str, body: str) -> Any:
516
+ fn = _CELL_MAGICS.get(name)
517
+ if fn is None:
518
+ raise NameError(f"UsageError: Cell magic function '%%{name}' not found.")
519
+ return fn(args, body)
520
+
521
+
522
+ class _ShellResult(list):
523
+ """Result of ``!cmd`` — list of stripped output lines."""
524
+
525
+ def __init__(self, lines: list[str], returncode: int) -> None:
526
+ super().__init__(lines)
527
+ self.returncode = returncode
528
+
529
+ @property
530
+ def n(self) -> str: # IPython compat
531
+ return "\n".join(self)
532
+
533
+ @property
534
+ def s(self) -> str: # IPython compat
535
+ return " ".join(self)
536
+
537
+
538
+ def __omp_shell(cmd: str) -> _ShellResult:
539
+ proc = subprocess.run(
540
+ cmd,
541
+ shell=True,
542
+ stdout=subprocess.PIPE,
543
+ stderr=subprocess.STDOUT,
544
+ text=True,
545
+ )
546
+ if proc.stdout:
547
+ sys.stdout.write(proc.stdout)
548
+ lines = [line for line in (proc.stdout or "").splitlines()]
549
+ return _ShellResult(lines, proc.returncode)
550
+
551
+
552
+ # ---------------------------------------------------------------------------
553
+ # Display dispatch
554
+ # ---------------------------------------------------------------------------
555
+
556
+
557
+ _REPR_MIMES = [
558
+ ("_repr_html_", "text/html"),
559
+ ("_repr_markdown_", "text/markdown"),
560
+ ("_repr_svg_", "image/svg+xml"),
561
+ ("_repr_png_", "image/png"),
562
+ ("_repr_jpeg_", "image/jpeg"),
563
+ ("_repr_json_", "application/json"),
564
+ ("_repr_latex_", "text/latex"),
565
+ ]
566
+
567
+
568
+ def _coerce_image_bytes(value: Any) -> str:
569
+ if isinstance(value, (bytes, bytearray)):
570
+ return base64.b64encode(bytes(value)).decode("ascii")
571
+ if isinstance(value, str):
572
+ return value
573
+ return base64.b64encode(repr(value).encode("utf-8")).decode("ascii")
574
+
575
+
576
+ def _mime_bundle(value: Any) -> dict:
577
+ """Build a Jupyter-style MIME bundle for ``value``.
578
+
579
+ Honors ``_repr_mimebundle_`` first, falls back to individual ``_repr_*_``
580
+ accessors, and always provides ``text/plain``.
581
+ """
582
+ bundle: dict[str, Any] = {}
583
+
584
+ mimebundle = getattr(value, "_repr_mimebundle_", None)
585
+ if callable(mimebundle):
586
+ try:
587
+ data = mimebundle()
588
+ except Exception:
589
+ data = None
590
+ if isinstance(data, tuple):
591
+ data = data[0]
592
+ if isinstance(data, dict):
593
+ bundle.update({str(k): v for k, v in data.items()})
594
+
595
+ for attr, mime in _REPR_MIMES:
596
+ if mime in bundle:
597
+ continue
598
+ repr_fn = getattr(value, attr, None)
599
+ if not callable(repr_fn):
600
+ continue
601
+ try:
602
+ data = repr_fn()
603
+ except Exception:
604
+ continue
605
+ if data is None:
606
+ continue
607
+ if mime in ("image/png", "image/jpeg"):
608
+ bundle[mime] = _coerce_image_bytes(data)
609
+ else:
610
+ bundle[mime] = data
611
+
612
+ if "text/plain" not in bundle:
613
+ try:
614
+ bundle["text/plain"] = repr(value)
615
+ except Exception:
616
+ bundle["text/plain"] = f"<unrepr {type(value).__name__}>"
617
+
618
+ return bundle
619
+
620
+
621
+ def _emit_display(bundle: dict, *, kind: str = "display") -> None:
622
+ rid = _STATE.current_id
623
+ if rid is None:
624
+ return
625
+ _emit({"type": kind, "id": rid, "bundle": bundle})
626
+
627
+
628
+ def __omp_display(value: Any, *, raw: bool = False, kind: str = "display") -> None:
629
+ if raw:
630
+ if not isinstance(value, dict):
631
+ raise TypeError("display(..., raw=True) requires a MIME bundle dict")
632
+ bundle = {str(k): v for k, v in value.items()}
633
+ if "text/plain" not in bundle:
634
+ bundle["text/plain"] = ""
635
+ _emit_display(bundle, kind=kind)
636
+ return
637
+ _emit_display(_mime_bundle(value), kind=kind)
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Matplotlib post-cell flush
642
+ # ---------------------------------------------------------------------------
643
+
644
+
645
+ def _flush_matplotlib_figures() -> None:
646
+ plt = sys.modules.get("matplotlib.pyplot")
647
+ if plt is None:
648
+ return
649
+ try:
650
+ fignums = list(plt.get_fignums())
651
+ except Exception:
652
+ return
653
+ for num in fignums:
654
+ try:
655
+ fig = plt.figure(num)
656
+ buf = io.BytesIO()
657
+ fig.savefig(buf, format="png", bbox_inches="tight")
658
+ data = base64.b64encode(buf.getvalue()).decode("ascii")
659
+ _emit_display({"image/png": data, "text/plain": f"<Figure {num}>"})
660
+ plt.close(fig)
661
+ except Exception:
662
+ continue
663
+
664
+
665
+ # Force a non-interactive backend before user code imports matplotlib. Set as
666
+ # environ default so the user can still override it explicitly.
667
+ os.environ.setdefault("MPLBACKEND", "Agg")
668
+
669
+
670
+ # ---------------------------------------------------------------------------
671
+ # Builtin injection
672
+ # ---------------------------------------------------------------------------
673
+
674
+
675
+ def _install_builtins(ns: dict) -> None:
676
+ ns["display"] = __omp_display
677
+ ns["__omp_display"] = __omp_display
678
+ ns["__omp_magic"] = __omp_magic
679
+ ns["__omp_magic_cell"] = __omp_magic_cell
680
+ ns["__omp_shell"] = __omp_shell
681
+
682
+
683
+ _install_builtins(_STATE.user_ns)
684
+
685
+
686
+ # ---------------------------------------------------------------------------
687
+ # Source execution (split last expression for rich display)
688
+ # ---------------------------------------------------------------------------
689
+
690
+
691
+ def _exec_source(source: str, ns: dict) -> None:
692
+ """Compile + execute ``source``; if the last node is an expression, route
693
+ its value through ``__omp_display`` so dataframes/figures render rich."""
694
+ try:
695
+ module = ast.parse(source, mode="exec")
696
+ except SyntaxError:
697
+ raise
698
+
699
+ if not module.body:
700
+ return
701
+
702
+ last = module.body[-1]
703
+ if isinstance(last, ast.Expr):
704
+ body_module = ast.Module(body=module.body[:-1], type_ignores=[])
705
+ expr_module = ast.Expression(body=last.value)
706
+ ast.copy_location(expr_module, last)
707
+ body_code = compile(body_module, "<cell>", "exec")
708
+ expr_code = compile(expr_module, "<cell>", "eval")
709
+ exec(body_code, ns)
710
+ value = eval(expr_code, ns)
711
+ if value is not None:
712
+ __omp_display(value, kind="result")
713
+ return
714
+
715
+ code = compile(module, "<cell>", "exec")
716
+ exec(code, ns)
717
+
718
+
719
+ # ---------------------------------------------------------------------------
720
+ # Signal handling
721
+ # ---------------------------------------------------------------------------
722
+
723
+
724
+ def _install_idle_sigint() -> None:
725
+ try:
726
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
727
+ except (OSError, ValueError):
728
+ # Some platforms (Windows in non-console mode) reject this; fine.
729
+ pass
730
+
731
+
732
+ def _install_exec_sigint() -> None:
733
+ try:
734
+ signal.signal(signal.SIGINT, signal.default_int_handler)
735
+ except (OSError, ValueError):
736
+ pass
737
+
738
+
739
+ def _start_parent_watchdog() -> None:
740
+ """Self-terminate when the host process dies.
741
+
742
+ The main loop only exits when stdin EOFs, which only happens once user
743
+ code finishes and the next ``readline`` call returns. If the host gets
744
+ SIGKILL mid-execution (or any way that skips graceful shutdown) the
745
+ runner would otherwise outlive its parent and keep holding kernel
746
+ state. Poll ``os.getppid()`` instead and ``os._exit`` the moment we get
747
+ reparented \u2014 covers POSIX hosts. Windows has no reliable ppid
748
+ equivalent; there we still bail out on the next stdin read.
749
+ """
750
+ if os.name != "posix":
751
+ return
752
+ original_ppid = os.getppid()
753
+ if original_ppid <= 1:
754
+ return
755
+
756
+ def watch() -> None:
757
+ while True:
758
+ try:
759
+ if os.getppid() != original_ppid:
760
+ os._exit(0)
761
+ except Exception:
762
+ return
763
+ time.sleep(10)
764
+
765
+ thread = threading.Thread(target=watch, name="omp-parent-watchdog", daemon=True)
766
+ thread.start()
767
+
768
+
769
+ # ---------------------------------------------------------------------------
770
+ # Request dispatch
771
+ # ---------------------------------------------------------------------------
772
+
773
+
774
+ def _handle_request(req: dict) -> None:
775
+ if req.get("type") == "exit":
776
+ sys.exit(0)
777
+
778
+ rid = str(req.get("id"))
779
+ code = req.get("code", "")
780
+ _STATE.current_id = rid
781
+ _STATE.cancel_requested = False
782
+ _STATE.execution_count += 1
783
+ _emit({"type": "started", "id": rid})
784
+
785
+ status: str = "ok"
786
+ cancelled = False
787
+
788
+ try:
789
+ transformed = transform_cell(code)
790
+ except SyntaxError as exc:
791
+ _emit_error(rid, exc)
792
+ _emit({
793
+ "type": "done",
794
+ "id": rid,
795
+ "status": "error",
796
+ "executionCount": _STATE.execution_count,
797
+ "cancelled": False,
798
+ })
799
+ _STATE.current_id = None
800
+ return
801
+
802
+ _install_exec_sigint()
803
+ try:
804
+ _exec_source(transformed, _STATE.user_ns)
805
+ except KeyboardInterrupt:
806
+ cancelled = True
807
+ status = "error"
808
+ _emit_error(rid, KeyboardInterrupt("Execution interrupted"))
809
+ except SystemExit:
810
+ raise
811
+ except BaseException as exc: # noqa: BLE001 - we want to surface every user error
812
+ status = "error"
813
+ _emit_error(rid, exc)
814
+ finally:
815
+ _install_idle_sigint()
816
+ try:
817
+ _flush_matplotlib_figures()
818
+ except Exception:
819
+ pass
820
+
821
+ _emit({
822
+ "type": "done",
823
+ "id": rid,
824
+ "status": status,
825
+ "executionCount": _STATE.execution_count,
826
+ "cancelled": cancelled,
827
+ })
828
+ _STATE.current_id = None
829
+
830
+
831
+ def _emit_error(rid: str, exc: BaseException) -> None:
832
+ tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
833
+ _emit({
834
+ "type": "error",
835
+ "id": rid,
836
+ "ename": type(exc).__name__,
837
+ "evalue": str(exc),
838
+ "traceback": [line.rstrip("\n") for line in tb_lines],
839
+ })
840
+
841
+
842
+ # ---------------------------------------------------------------------------
843
+ # Main loop
844
+ # ---------------------------------------------------------------------------
845
+
846
+
847
+ def main() -> None:
848
+ sys.stdout = _StreamProxy("stdout")
849
+ sys.stderr = _StreamProxy("stderr")
850
+ _install_idle_sigint()
851
+ _start_parent_watchdog()
852
+
853
+ stdin = sys.__stdin__
854
+ if stdin is None:
855
+ return
856
+
857
+ for raw_line in stdin:
858
+ line = raw_line.strip()
859
+ if not line:
860
+ continue
861
+ try:
862
+ req = json.loads(line)
863
+ except json.JSONDecodeError as exc:
864
+ _emit({
865
+ "type": "error",
866
+ "id": "",
867
+ "ename": "ProtocolError",
868
+ "evalue": f"Invalid JSON request: {exc}",
869
+ "traceback": [],
870
+ })
871
+ continue
872
+ try:
873
+ _handle_request(req)
874
+ except SystemExit:
875
+ return
876
+
877
+
878
+ if __name__ == "__main__":
879
+ main()