README.md

pyrepl #

Named, persistent Python sessions for Claude Code (and for you, from the shell).

A session is a Python process that keeps its namespace — imports, loaded data, functions, open connections — between tool calls and across conversations, until it is killed. Claude uses it the way you use a Jupyter kernel: load the 200 MB log once, then ask twenty questions of it, instead of re-parsing it in twenty python3 -c calls. Every session also comes with a small toolkit (tk) covering what Claude would otherwise do through its own Read, Edit, Grep and Bash tools, and sessions can run on other hosts over ssh.

$ pyrepl exec chirp 'import numpy as np; x = np.load("/var/tmp/cap.npy")'
[Created session 'chirp' (python 3.12.15, cwd /home/me/work).]
$ pyrepl exec chirp 'x.shape'
Out[2]: (480000,)
$ pyrepl sessions
chirp  pid 81234  python 3.12.15  up 2m  idle 40s  2 cells  210 MB  cwd /home/me/work
    names: np, x

Let Claude install it #

Paste this into Claude Code:

Install pyrepl from https://tangled.org/poggers.website/pyrepl — clone it and follow its AGENTS.md.

It checks prerequisites, clones to ~/tools/pyrepl, registers the MCP server, runs the tests, and asks before touching your ~/.claude/CLAUDE.md or settings.

Install by hand #

Requirements: Linux, bash, Claude Code, and Python ≥ 3.8 for the client and daemon (no third-party packages). Sessions themselves can run on any Python ≥ 3.6, e.g. a project venv; remote hosts need only sh and a python3.

git clone https://tangled.org/poggers.website/pyrepl ~/tools/pyrepl
claude mcp add -s user pyrepl -- ~/tools/pyrepl/pyrepl mcp

The client's first line names /usr/bin/python3.12. For another interpreter, pass it first; the daemon then runs on it too:

claude mcp add -s user pyrepl -- /path/to/python3.X ~/tools/pyrepl/pyrepl mcp

Restart Claude Code; /mcp should list pyrepl with six tools. Optionally put ~/tools/pyrepl on your PATH to use the CLI.

To have Claude reach for it by default, add something like this to your ~/.claude/CLAUDE.md:

Run code and shell commands through the pyrepl MCP and its tk toolkit rather than Bash. Call sessions first and reuse a matching session. Use pyrepl's memory tool for Claude Code memory files.

Optional, in ~/.claude/settings.json:

  • "allow": ["mcp__pyrepl__memory"]: the memory tool only touches the project's memory folder, so it is safe to allow outright. Don't allow mcp__pyrepl__exec the same way: it runs anything.
  • $SUDO_ASKPASS set to an askpass program in your environment, if you want root sessions or sudo() on hosts where sudo asks for a password.

Remove it with pyrepl stop and claude mcp remove -s user pyrepl.

How it fits together #

Claude Code ──stdio MCP──▶ pyrepl mcp ──unix socket──▶ pyrepld ──pipes──▶ worker.py (one per session)
                                                          ├──ssh──▶ sh -s ▶ python -c stub ▶ boot.py ▶ worker.py --stdio
                                                          │         (other hosts; the code comes over stdin)
                                                          └──sudo─▶ worker.py --stdio (root)
  • pyrepl is the client. pyrepl mcp is the MCP server Claude Code launches; the other subcommands are a CLI for you.
  • pyrepld.py is the daemon that owns the sessions. The client starts it on first use, on its own interpreter, as a transient systemd --user unit named pyrepld, with MemoryHigh=2G / MemoryMax=3G covering all sessions together (UNIT_PROPS in pyrepl). Without a user systemd manager it falls back to a plain detached process, with no memory limit. It survives logout only if lingering is enabled (loginctl enable-linger), and never survives a reboot.
  • worker.py is one session: a process holding one namespace, started by the first exec under a new name, in the caller's cwd and environment. A local session runs on $PYREPL_PYTHON, else the first of the config's pythons that runs, else the daemon's own python, unless python names another interpreter, such as a venv. That interpreter's minor version is the one remote sessions look for, which is what lets functions move between sessions.
  • tk.py is the toolkit every session preloads; boot.py and vendor/ (cloudpickle) travel to remote hosts with the worker.

MCP tools: exec, wait, interrupt, kill, sessions, memory.

CLI:

pyrepl exec NAME 'code' [-t SECS] [--python PATH] [--host HOST] [--sudo]
            [--pythons P1,P2] [--jump J1,J2]       # or: echo code | pyrepl exec NAME -
pyrepl wait NAME [-t SECS]
pyrepl interrupt NAME
pyrepl kill NAME
pyrepl sessions [NAME]
pyrepl start | stop

Behaviour #

  • Runs are synchronous up to a timeout. exec waits timeout seconds (default 30, max 600). If the code is still going, the call returns, the code keeps running, and wait collects the output and result later. A session runs one cell at a time; use another session for parallel work.
  • REPL semantics. Output from print(), subprocesses and C code is captured; if the last statement is an expression its repr comes back as Out[n]. Sessions run as a real __main__, so their functions pickle. input() gets EOF, and sys.exit() is caught; the session stays up.
  • interrupt raises KeyboardInterrupt in the running cell (it works even inside time.sleep). If the cell is stuck in C code that never checks for signals, kill the session.
  • No persistence, no replay. A namespace lives exactly as long as its process. Every cell is appended to ~/.local/state/pyrepl/<host>/<session>.py for reference; nothing ever re-runs it. Write results that matter to files.
  • Losses are reported. When a session dies (kill, crash, OOM, daemon stop) a note is written, and the next exec under that name opens with what was lost and where its code is. wait/interrupt on a dead session say how it died.
  • Idle sessions are swapped to disk. Every 10 minutes the daemon looks for sessions nobody has used for swap_after_hours (default 24): each one pickles its namespace (cloudpickle, like put) to ~/.local/state/pyrepl/<host>/swap/ and its process ends. The next exec or session("x") under that name starts it again where it was (same host, jump route, python and cwd), loads the pickle, and opens with a note: what would not pickle (sockets, open files, connections), threads that were running, and any result or output nobody had read. A swap not restored within swap_keep_days (default 7) is deleted and leaves a death record. A graceful stop (pyrepl stop, or SIGTERM from systemd or logout) swaps every user session the same way before ending, 4 at a time, for up to 90 s; a second SIGTERM stops at once. Swaps survive the restart. Loading a pickle runs code, so swap files are written 0600 in a 0700 directory and are loaded only while they are still regular files of yours with no group/other bits; anything else is refused and logged. sessions lists them, kill deletes one, pyrepl swap NAME swaps one now, and pyrepl swap runs the sweep now. Never swapped: root sessions (their namespace would land in your files, and bringing one back could need the password), sessions running a cell, and sessions with a bg() job still running (retried a period later).
  • Every reply mentions other sessions with runs still going or results nobody has read yet.
  • Sessions are per host and per user. With an NFS home, the MCP config follows you to every machine, but each machine runs its own daemon; state is kept under <host>/ so machines don't overwrite each other's records.
  • Output caps: a reply carries at most 30 000 chars of output (head and tail kept), a result repr at most 10 000 chars, and up to 2 M chars of unread output are buffered per session.

Toolkit (tk.py) #

Every new session preloads tk.py: one helper per Claude Code tool (read, readn, write, edit, edits, sed, splice, insert, glob, grep, sh, bg, fetch) plus cd, sudo, diff, memory and the session helpers below. git and systemd are modules (from pyrepl import git; see Modules). tkhelp() prints the list. Their results print as plain text (worker.py honours __pyrepl_show__) and stay usable as str/list values. Toolkit names are left out of the sessions listing, so the MCP instructions name the toolkit; otherwise agents don't know it is there. PYREPL_NO_TK=1 in the caller's env skips it. Sessions that already exist keep what they started with, so a change to tk.py reaches new sessions only.

sh():

  • str(r) is stdout. r.err is stderr, shown under [stderr]; merge=True folds them together in order. They are separate by default so that an ssh banner can't end up in real output.
  • It raises CommandError (which carries the Result) on a non-zero exit, because a bare [exit 1] is too easy to miss. check=False returns the Result instead, with r.code and r.ok: use it whenever non-zero is an answer rather than a failure (a grep with no match, test -e, diff), instead of tacking ; true onto the command.
  • A string runs as bash --norc -o pipefail -c. --norc matters: under ssh, bash sources ~/.bashrc even for -c, and a .bashrc that execs another shell would swallow the command. A list runs as argv with no shell at all: use that for anything holding a secret, and pass secrets with input=. sh uses bash whatever your login shell is.
  • sh(cmd, host="h") runs it on h, still as bash: the ssh line is the one a session there would use (config ssh and jump; it rides a session's ssh connection when one is open, but never starts one, since a master that ssh backgrounds would hold the stderr pipe open and sh() would wait on it), with exec /bin/sh -s as the only remote command, so h's login shell never parses the command; what its rc files print is dropped. cwd= and env= apply over there; input= can't, since stdin carries the command. Agents should never sh("ssh h ...") or scp: with tcsh as the login shell bash syntax breaks, and it skips the configured route.
  • sudo(cmd) is sh() through sudo -A with $SUDO_ASKPASS, so nobody rolls their own prompt. Without $SUDO_ASKPASS it runs sudo -n, which works where sudo needs no password and fails cleanly where it does.

read(path) returns the file's text exactly as stored (offset=/limit= pick lines), ready to change and write() back. readn(path) is the Read tool's numbered view, for looking only: line numbers that end up in a file built from it are a classic mistake, which is why the raw form is read.

grep() uses ripgrep when it is installed, else grep -rP (GNU grep), and falls back to Python for multiline=True or a grep without -P. Either way hidden files are skipped unless hidden=True (an explicitly hidden path is searched), glob= takes "*.py" or a list ("!*.min.js" excludes), and type= takes a ripgrep type name.

diff(a, b) is a unified diff between two files, two strings, or a file and a string (a string that names an existing file is read as that file; pass text=True to compare strings as given). n= sets context lines; it returns (no change) when they match.

memory reads and maintains Claude Code's auto-memory for the project (the memory/ folder under ~/.claude/projects/<project>/ and its MEMORY.md index): memory() lists it, .read(name), .write(name, text, index="hook") (the hook becomes the file's line in MEMORY.md), .edit(name, old, new), .delete(name). Names are bare file names, never paths. The folder comes from the git root of the cwd, else the cwd ($CLAUDE_CONFIG_DIR is honoured; $PYREPL_MEMORY_DIR overrides it all). The same thing is the memory MCP tool, which runs in the client with no session. It exists because auto mode's classifier treats memory edits made through shell commands or code cells as suspicious; a dedicated, narrow tool is easy to review, and safe to allow outright ("allow": ["mcp__pyrepl__memory"]) where exec is not.

Other sessions, other hosts #

Code in a session can open another session, here or on another host, and move objects between them. Objects cross by copy, never by reference, and it works the same whether the other session is local or remote.

staging_host = session("staging", host="staging-box")   # any name; attach: session("staging")
staging_host.call(fn, *args)          # run fn there, get its value back
staging_host.call("name", *args)      # call something already defined there
staging_host.call(connect, into="c")  # keep the value there (connections, files)
staging_host.put(rows=rows, parse=parse)
staging_host.get("result")            # several names give a tuple
staging_host.sudo()                   # staging-root: same host, as root
staging_host.pull("~/out.tar")        # a file's bytes; pull(path, dest) writes it here
staging_host.push("plot.png", "/tmp/plot.png")   # a file here (or bytes) to there
staging_host.kill(); sessions()

session("staging") attaches to a live staging whatever it is, root included; passing sudo=True/False also insists on it.

Files go with pull/push, not scp or a world-readable /tmp: they travel as plain bytes in 8 MB chunks through the hub, so they work between any two sessions, root ones and Python 3.6 ones included. Paths are as the side that opens them sees them (~ is its home). The file appears whole or not at all: it fills NAME.pyrepl-part (0600, since it may be a secret), then a rename. Overwriting keeps the destination's mode, and its owner when a root session writes it, and a symlink stays a link to the replaced file; a new file gets the source's mode (0600 for bytes). One file at a time: tar a directory first.

Through MCP, exec takes host and sudo for a new session, so an agent can also type cells straight into a remote session.

What a copied function sees. cloudpickle (vendored in vendor/) sends functions, lambdas and closures by value, closure cells included. Once a function lands, it runs in the target session's namespace: names defined there resolve there, and names it uses that the target lacks come along from the sender (helpers travel). Names both sides have stay the target's; put a name to replace it. A closure that was put keeps its state over there, and that state drifts from the sender's copy from then on: that is a copy, and moving something is a copy followed by del. Functions inside containers (a dict of callbacks) keep the sender's globals. Open text files come back as a StringIO of their contents (cloudpickle does that); sockets and the like refuse to copy, with a hint about into=.

Versions. Every blob is tagged with the sender's Python major.minor. On a mismatch the receiver loads it with an unpickler that refuses cloudpickle's code reconstructors: data loads, code is refused with a reason. Without the guard a 3.11 function loaded into 3.12 "worked" for a trivial body, which is worse than failing.

Hub. The worker sends requests (open, put, get, call, module, ssh, interrupt, kill, sessions) over its control pipe and pyrepld answers on it, so remote sessions can use the hub as well. A target is busy for the length of the operation, like a cell, and its transcript records it, with the function's source for a call. Interrupting a caller that's waiting on call also interrupts the call over there. Copies between two remote sessions go through this machine.

Remote sessions need only sh and some python 3.6+ on the far side: no shared home, nothing installed, nothing written there.

  1. pyrepld runs ssh -T -o BatchMode=yes [-J JUMP,...] HOST exec /bin/sh -s. That fixed line is the whole remote command, so the login shell (whatever it is: tcsh, fish, ...) has nothing to mangle.
  2. Over stdin goes a short sh script (REMOTE_SCRIPT) that looks for python in: the caller's pythons, the config's pythons, then python3.14 ... python3.6, python3, /usr/libexec/platform-python, python. Entries are paths, directories (python3.X, python3, python inside) or globs (expanded there, in sorted order). The first one on the local sessions' minor version wins; otherwise the first that runs 3.6+. python= pins one exact interpreter instead.
  3. It prints PYREPL-BOOT, then execs that python on a small -c stub. Only after the marker does pyrepld send the payload (boot.py with worker.py, tk.py and vendor/ as source, zlib + base64, about 40 KB), so no shell can read ahead into it. The stub reads it byte-exact from fd 0 and boot.py serves it from memory (an import hook for vendor/, linecache for tracebacks), then runs worker.py as --stdio. It is built per session, so edits to worker.py and tk.py reach new remote sessions too.

Cell data never goes into the script: the only variable parts are paths and host names, checked against strict patterns (GLOB_RE, JUMP_RE). After the start, ssh is a byte pipe: frames on stdin/stdout, raw output on stderr. Anything before the worker's "ready" (banners, rc-file noise) is dropped unless the start fails. All sessions on a host share one connection (ControlMaster, socket in the run dir, kept 10 minutes after the last one closes); a route through jump hosts gets its own. Keys only. Interrupt is a control message because our signals can't reach over there. Kill closes the link and the worker exits on EOF.

On python 3.6 or 3.7 there is no cloudpickle (it needs 3.8), so such a session copies data only. That is the intended degraded mode: if a function needs to run there, write it in that session.

Root sessions. With sudo = auto (the default), pyrepld first tries sudo -n: on a NOPASSWD host, nobody is asked anything. If sudo wants a password, the open is answered with need_password, the caller (the pyrepl client or tk.session) asks the askpass program named by $SUDO_ASKPASS and opens again; with no $SUDO_ASKPASS, the open fails with an error saying so. nopasswd never asks (and fails if sudo wants one); askpass always asks, skipping the -n try, and so fails at once when there is no askpass program. askpass needs a display, so the password is asked here and relayed: pyrepld writes it as the first line on the new worker's stdin, for sudo -k -S (remotely, right after PYREPL-BOOT, before the payload). -k makes sudo always read that line; with cached credentials it would skip it and the worker would get the password instead (the worker drops non-JSON lines without echoing them, as a second guard). The password is never in a cell, argv or a log. A wrong password makes sudo ask again and wait, so the start fails after 40 s with "no answer from sudo". Elevation is per session, not per call: a root worker can't share a process with yours. Open root sessions from a session on this machine: askpass on a remote has no display.

Lifetime. A remote session dies with its link (VPN blip, reboot). If a session died on its own, its name stays tied to where it ran: a later exec without the same host/sudo is refused instead of quietly running here as you. A deliberate kill frees the name. Possible later: a worker that keeps running on its host behind a unix socket, reached through an ssh forward, so it can survive a dropped link.

Modules (pyrepl.NAME) #

Helpers bigger than a toolkit function, or that only make sense at your site, are modules: not preloaded, imported when needed from the pyrepl package. pyrepl ships two in modules/:

  • git: repositories as Python objects. git.status(), git.log(10), git.show(rev), git.cat("HEAD:path"), git.blame(path), git.branches(), git.commit("msg", all=True)... and git("log -3") for anything else (argv, no shell). Reads come straight from .git: refs, packed-refs, loose and packed objects with deltas, worktrees, alternates; log() walks commits and show() diffs trees natively. status, diff, blame, config and writes go through git with -z machine formats. SHA-256 and reftable repos fall back to git for reads too.
  • systemd: systemd.status("pyrepld", user=True), units(), props(), start/stop/restart/reload, enable/disable, run([...]) (a transient service), journal(unit, since="-1h", grep=...), call() for any D-Bus method. It binds the machine's libsystemd with ctypes (sd-bus, sd-journal) and falls back to systemctl/journalctl without it. Changes to system units are refused unless the session is root: they are never sent from a user process, because polkit can pop an authentication dialog on the desktop even for non-interactive calls (seen on RHEL 8).

Your own go in ~/.config/pyrepl/modules/ ($XDG_CONFIG_HOME), one plain .py file each:

# ~/.config/pyrepl/modules/cad.py
"""CAD tool helpers: module_env(), as_user()."""
from tk import sh
def module_env(mod): ...

>>> from pyrepl import cad; cad.module_env("lyx/2.3.6")
  • Nothing is preloaded. The MCP server lists each module with the first line of its docstring in the instructions agents get, so they know what exists and import it when it helps; modules() prints the same list in a session. Make that first line say what is in the file.
  • The import hook is first on sys.meta_path and owns the pyrepl name: nothing on sys.path can stand in for it (a pyrepl/ directory in the cwd, say), and module names can't clash with real packages. A file of yours shadows a shipped module of the same name.
  • pyrepld reads the file and sends the source, so remote and root sessions import the same modules without anything installed there; local sessions read it directly. Write them for Python 3.6 if they will run on old hosts.
  • importlib.reload(cad) picks up an edit. Functions from a module go to other sessions by reference: staging_host.call(cad.module_env, ...) imports pyrepl.cad over there.
  • The list in the instructions is read when Claude Code starts the server, so a new file shows up in the next conversation (modules() sees it now).

Config #

~/.config/pyrepl/config.json ($XDG_CONFIG_HOME), optional, read at every session start, so edits need no restart (except for the local default python, picked when pyrepld starts). Top-level keys are defaults; each pattern under hosts (fnmatch, on the host name as given) that matches overrides them, in file order.

{
  "pythons": ["/opt/python/3.11/bin/python3.11"],
  "sudo": "auto",
  "hosts": {
    "lab-*":  {"jump": ["gw1", "me@gw2:2222"], "sudo": "nopasswd"},
    "odd-box": {"ssh": ["tsh", "ssh"], "multiplex": false}
  }
}
  • pythons: more places to look (see Remote sessions); local sessions use the host-matched list for this machine.
  • jump: jump hosts in order (ssh -J). A request's jump wins, and jump: [] turns a configured one off.
  • sudo: auto, nopasswd or askpass (see Root sessions). Local root sessions use this machine's entry.
  • ssh: the command and its options, replacing the defaults (-T, BatchMode, timeouts). -J and the ControlMaster options are still added, so a non-OpenSSH command wants "multiplex": false and no jump.
  • swap_after_hours (24; 0 turns swapping off) and swap_keep_days (7): see Behaviour. Read from this machine's entry at each sweep.

Security #

A session runs arbitrary code as you, so access is limited to you:

  • The socket lives in $XDG_RUNTIME_DIR/pyrepl/ (mode 0700). If that variable is unset, /tmp/pyrepl-<uid>/pyrepl/, which the daemon refuses to use unless it is a real directory owned by you.
  • The daemon checks each connecting peer's uid (SO_PEERCRED), and the client checks the daemon's, since every exec sends the caller's environment.
  • Transcripts can contain secrets that were in your code; their directory is created 0700.
  • Remote sessions use ssh with keys only (BatchMode); nothing is written on the remote host. A sudo password is relayed once to sudo -S and is never in a cell, argv or a log.

There is no network listener.

Troubleshooting #

  • Daemon log: $XDG_RUNTIME_DIR/pyrepl/pyrepld.log (also in the journal, if you can read it: journalctl --user -u pyrepld).
  • pyrepl sessions shows each session's memory. A session killed by SIGKILL while the daemon runs under systemd most likely hit the shared MemoryMax; kill idle sessions or raise the limit in UNIT_PROPS, then pyrepl stop (it restarts on the next call).
  • After updating the code, run pyrepl stop so the next call starts the new daemon. Sessions are swapped to disk first and come back on next use; running cells and root sessions are lost (it prints which). Changes to worker.py, tk.py and boot.py alone reach new sessions without a restart.

Tests #

./test_pyrepl.sh                    local behaviour, about 20 s
PYTHON=/path/to/python3.X ./test_pyrepl.sh    the same, client and daemon on that python
tests/smoke.py HOST                 remote sessions, copies, root; HOST is any ssh host you can reach with keys
tests/smoke.py HOST --real-sudo     same, with real sudo and your askpass (two prompts)

Both run against a throwaway daemon (their own XDG_RUNTIME_DIR and XDG_STATE_HOME), so they don't touch your real sessions. tests/smoke.py requires an explicit test host and expects /usr/bin/python3.11 and /usr/bin/python3.12 locally, plus the interpreter paths it checks on the remote host (including /opt/python/*/bin for the glob check). Adapt those paths to your test environment. Its jump-host check jumps through the machine it runs on unless --jump=HOST names another.

Without --real-sudo, root sessions go through tests/fake-sudo and tests/fake-askpass (env PYREPL_SUDO / PYREPL_REMOTE_SUDO), which checks the relay but runs as you. Don't point tests at production hosts.

Design choices #

pyrepl started as a rework of a simpler single-namespace REPL server. What it does differently, and why:

instead of pyrepl does why
One shared namespace Named sessions, one process each Parallel conversations stop overwriting each other. You can interrupt or kill one session without touching the others.
A TCP port on localhost, no auth Unix socket in $XDG_RUNTIME_DIR (0700), peer uid checked On a multi-user host, any account could connect to a TCP port and run code as you.
Guessing from the code's text which runs are slow Every run starts synchronously. After timeout (30 s default) the call returns and the run keeps going, and wait collects it. Text heuristics misfire: ", ".join(x) is not a background job.
print() going to the daemon's log Output is captured, including from subprocesses An agent can't see output that isn't returned to it.
No way to stop a run interrupt raises KeyboardInterrupt in the session, even inside sleep Code runs on the worker's main thread, so SIGINT reaches it.
Wake files dropped in an inbox wait Nothing in Claude Code watches a directory.
Replaying code to rebuild a session Nothing is ever replayed Replaying side effects (writes, deploys) is worse than losing state.
Idle sessions holding memory forever A day idle, a session is pickled to disk and restored on next use; a week unrestored, the pickle is deleted Forgotten sessions add up under the shared 3 GB cap. Pickling keeps state without replaying code; what doesn't pickle is named on restore.