# REPL runtime protocol `python -m rlm.repl` starts a CPython REPL runtime that executes code cells in one persistent `__main__` namespace on a single asyncio event loop. The wire format is newline-delimited JSON: one object per line, UTF-8, no other framing. The current protocol version is `3`; the runtime announces it in the `ready` event. ## Channels - Requests arrive on fd 0 (stdin). - Events leave on a private dup of the original fd 1, made before anything else runs. Every frame is one locked write sequence, so frames never interleave. - Python-level writes through `sys.stdout`/`sys.stderr` are intercepted at write time, tagged with the writing context's cell id, and shipped straight to the protocol. - fds 1 and 2 are redirected into pipes at startup; pump threads read them and ship the bytes as `stdout`/`stderr` events with `id: null` — raw fd bytes (`os.write`, `sys.stdout.buffer.write`, C extensions, subprocesses) are never attributed to a cell. Neither channel can corrupt protocol framing. Ordering is preserved within each channel, not across them. - fd 0 is rebound to `/dev/null` after the reader thread takes it, so user `input()` sees EOF instead of consuming protocol frames. ## Requests | Request | Fields | |---|---| | `execute` | `{"type":"execute","id":str,"code":str}` | | `interrupt` | `{"type":"interrupt","id"?:str}` — no reply | | `host_reply` | `{"type":"host_reply","id":str,"data":{"status":"ok","result":{...}}}` or an error envelope — no reply | | `snapshot` | `{"type":"snapshot","id":str,"path":str,"manifest_path":str,"max_bytes"?:int,"max_variable_bytes"?:int,"prune_oversized"?:bool}` | | `restore` | `{"type":"restore","id":str,"path":str}` | | `list_names` | `{"type":"list_names","id":str}` | | `mcp_status` | `{"type":"mcp_status","id":str,"servers":[str,...],"timeout_ms"?:number}` — host-side view query: per-server tool listing (opens each server on demand, bounded by `timeout_ms` per server; default 10s); the `done` frame carries `connections: [{server, tools: [{name, description}] | null, error: str | null}]` | | `bash_activity` | `{"type":"bash_activity","id":str,"action":"list"|"tail"|"kill","activityId"?:str,"lines"?:int}` — out-of-band even during a running cell; tail lines 1–200, response capped at 16 KiB; opaque IDs resolve only against this kernel’s handles | | `shutdown` | `{"type":"shutdown","id"?:str}` | Requests other than `interrupt`, `host_reply`, and `bash_activity` run strictly in order, one at a time. A malformed line produces `{"event":"error","id":null,"ename":"ProtocolError",...}` and the runtime keeps serving. Closing stdin is equivalent to `shutdown`. ## Events - `{"event":"ready","protocol":3,"python":"3.13.11"}` — sent once at startup; the handshake. No banner precedes it. - `{"event":"stdout"|"stderr","id":str|null,"text":str}` — captured output. `id` is the cell whose Python execution context performed the write; asyncio tasks inherit the spawning cell's id (even after that cell finished). `null` for user threads, raw fd writes (`os.write`, C extensions, subprocesses), and anything else without provable ownership — bytes read from the fd pipes are never attributed to a cell. A Python-level write ships at most 64 Ki characters per frame; a larger write arrives as multiple events in order. - `{"event":"result","id":str,"text":str}` — `repr` of the cell's trailing expression when the body ends in an expression whose value is not `None`. The value is also bound to `_` in the namespace. The `repr` content is capped at 1,048,576 characters; a longer `repr` is truncated to the cap and a trailing truncation marker is appended after it, so the total `text` can exceed the cap by the marker's length. - `{"event":"display","id":str|null,"data":{mime:payload,...}}` — one dict of MIME type to JSON payload, shipped verbatim from `emit()`. A payload whose JSON encoding exceeds 16 Mi characters is refused: `emit()` raises `ValueError` in the calling cell. `id` rides task context: an asyncio task spawned by a cell keeps that cell's id even after the cell finishes; user threads emit `null`. - `{"event":"host_request","id":str,"data":{...}}` — one typed request from runtime code to the host; the host answers with a `host_reply` request carrying the same id. `data` is subject to the same encoding cap as a `display` payload: `host_request()` raises `ValueError` in the calling cell instead of sending an oversized request. - `{"event":"error","id":str|null,"ename":str,"evalue":str,"traceback":[str,...]}` — `evalue` and each `traceback` entry are capped like `result` text (same cap, same trailing marker). - `{"event":"done","id":str,"status":"ok"|"error"}` — exactly one per id'd request, always after all of that request's other events. A snapshot `done` adds `saved`, `skipped`, `pruned`, `bytes`; a restore `done` adds `restored`, `failed`; a `list_names` `done` adds `names`; a failed snapshot/restore adds `reason`. Bash activity `done` carries `activities` (list), `tail` (tail), or `killed` (kill); `status:"error"` with `reason` on unknown IDs. The daemon advertises `kernel_bash_activity`; clients poll `list_kernel_bash` for updates (no push events). Each row contains opaque `id`, `command`, `pid`, `durationMs`, and `status` (`running` or `finished`). Finished rows are retained for the latest 64 completions within a live kernel only; restart invalidates all IDs. `tail_kernel_bash` returns `{id,tail}`, and `kill_kernel_bash` returns `{id,killed}`. Neither action accepts a PID. Restoring a missing file reports `status:"ok"` with empty `restored`/`failed` lists and `reason:"snapshot not found"`. Before a cell's `done`, the runtime drains both channels: tagged Python-level writes ship synchronously from the writing thread, and the fd pipes are fenced with a marker byte sequence awaited in the pumps, so every byte the cell wrote synchronously — including direct fd writes — precedes its `done`. Ordering between a cell's Python-level writes and its raw fd writes is not guaranteed (two channels). ## Execution Cells compile with `PyCF_ALLOW_TOP_LEVEL_AWAIT` and run as tasks on the persistent event loop, so `await` works at top level and background tasks created by a cell keep running between cells. Each cell's source is registered in `linecache` under ``, so tracebacks show the offending source line. Tracebacks are plain `traceback` formatting with the runtime's own frames stripped, keeping cell and library frames; no colors, no decoration. ## Interrupt `{"type":"interrupt"}` raises `KeyboardInterrupt` in the running cell. Without an `id` the interrupt applies to the running request, or — when none is running yet — to the next queued one; with an `id` it applies to that request only. An interrupt that arrives before its request starts executing is parked and delivered the moment the request becomes active, so `execute` + `interrupt` written back-to-back still interrupts the cell. A request stays interrupt-targetable until its `done` event is emitted: this covers the post-run trailing-expression `repr` and output drain. Interrupts for finished or unknown requests are dropped. Delivery: the reader thread sends SIGINT to the main thread (also the loop thread); the handler asks asyncio which task's step the signal interrupted. On Windows (no `signal.pthread_kill`) the reader instead cancels the active cell task on the loop, so await-suspended cells interrupt normally but cells blocked in synchronous code cannot be broken (best-effort parity): - The active cell's own task is mid-step (sync bytecode such as a `time.sleep` loop, or a blocking syscall such as `selectors.select()` woken by EINTR): the handler raises `KeyboardInterrupt` directly and it propagates out of the cell task. - The loop is idle in `select()` (the cell is suspended at an `await`) or a different task — a background task or the runtime itself — is mid-step: raising there would land in the wrong context, so the handler cancels the active cell task and the runtime reports the cancellation as a `KeyboardInterrupt`. When the mid-step task is a background task, the handler also raises `KeyboardInterrupt` into it: a background task blocked in synchronous code occupies the only thread, so it receives the `KeyboardInterrupt` (and dies with it) to unblock the loop and let the cancel take effect. Limitation: the interrupt lands at the await point as a cancellation, so user code catching `KeyboardInterrupt` around an `await` does not intercept it. Both paths end with an `error` event (`ename` `KeyboardInterrupt`) and `done` with `status:"error"`; the runtime keeps serving. When nothing is running or queued, SIGINT and interrupt requests are ignored. ## Display bridge `from rlm.repl import emit` inside a cell (or any user thread) ships a `display` event. `emit(data)` takes one non-empty dict keyed by MIME type strings; the dict is forwarded verbatim as the event's `data`. ## Host bridge `await rlm.repl.host_request(data)` ships a `host_request` event with a runtime-minted id and awaits the matching `host_reply`, returning its `data` dict verbatim. Replies are routed on the reader thread like `interrupt` — never through the request queue, since the awaiting cell is itself the in-flight execute. Replies for unknown ids, or for a request whose awaiting cell was cancelled, are dropped. `rlm.repl.is_active()` reports whether the process is serving the protocol (importing the module does not count). ## Snapshot / restore `snapshot` serializes the user namespace with `dill` (recurse mode), one name at a time: `_`-prefixed names and `{rlm, mcp, bash, asyncio, In, Out, get_ipython, exit, quit, open}` are always skipped; a name whose pickle exceeds `max_variable_bytes` or would push the total over `max_bytes` is skipped and reported. With `prune_oversized`, only names exceeding the per-variable cap (`max_variable_bytes`) are also deleted from the namespace and listed in `pruned`; names skipped for the aggregate `max_bytes` cap are reported in `skipped` but kept in the namespace. The payload is written atomically (tmp file + `os.replace`) and a JSON manifest (`version`, `savedNames`, `skipped`, `pruned`, `bytes`, `pythonVersion`, `timestamp`) is written to `manifest_path`. A manifest write failure fails the snapshot (and nothing is pruned). `restore` loads the payload and revives each name independently; a missing file yields an ok empty restore with `reason:"snapshot not found"`, a corrupt file fails with a `reason`, and per-name failures are listed in `failed`. Names `In`, `Out`, and `get_ipython` in a payload are never restored. `dill` is imported lazily; when unavailable, snapshot and restore fail with `status:"error"` and a `reason`. `list_names` replies with `done` carrying `names`: the sorted user-defined top-level names under the same filter the snapshot applies. ## Shutdown `shutdown` (or stdin EOF) kills live `rlm.bash` child process groups, replies `done` (when the request carried an id), stops the loop, and exits 0.