# 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 to the protocol. While the writing cell is live they are batched: a batch ships when it passes 8 KiB or 50 ms, whichever comes first, and any other frame ships the pending batch first. Text order within the channel is preserved; only the frame count changes, so a print-heavy cell cannot flood the host with one frame per `print()`. Writes from a user thread or from a task outliving its cell ship immediately, unbatched. - 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}` | | `signatures` | `{"type":"signatures","id":str,"skills":[str,...]}` | | `shutdown` | `{"type":"shutdown","id"?:str}` | Requests other than `interrupt` and `host_reply` 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","capabilities":["signatures"]}` — sent once at startup; the handshake. No banner precedes it. `capabilities` is additive: a host reads `protocol` and ignores the list, and it sends a `signatures` request only when the list advertises it (an older runtime answers an unknown request type with a `ProtocolError` event and a failed `done`). - `{"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. - `{"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. - `{"event":"display","id":str|null,"data":{mime:payload,...}}` — one dict of MIME type to JSON payload, shipped verbatim from `emit()`. `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. - `{"event":"error","id":str|null,"ename":str,"evalue":str,"traceback":[str,...]}` — for a cell failure the runtime appends what it can generate: the published signature plus the nearest legal name for a Python call-shape error, and one repeat line when the identical cell failed before (see below). - `{"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`. 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: the pending tagged batch ships, 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. The boundary decides on the *evaluated value*, not on the compiled flags: a value that is awaitable is awaited, so `f(x)` and `await f(x)` agree for a call the model left bare as the cell's last expression. Two values are kept instead, because both have already started and awaiting them would turn a background launch into a blocking wait: a live `bash()` handle (awaiting it would also suppress the completion notice) and a started `asyncio.Task`. The cell's trailing value is also returned settled — `await` on it (or on the next cell's `_`) returns it unchanged instead of raising `TypeError`. 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. `signatures` imports the host's skill names, indexes every published call, and replies with `done` carrying `calls` (the number indexed), `failed` (a `[{name, reason}, ...]` list for names that will not import) and `digest` (one generated block: one line per published call, plus one shape line per result type). The runtime keeps that index and renders the remedies it appends to later cell errors from it, so an error can never name a signature the code has already changed. Without a `signatures` request the runtime indexes the skill-shaped modules in the cell namespace instead, which covers the wrapped skills the bootstrap installs. ## 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.