- Python 92%
- Shell 8%
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
sessionsfirst and reuse a matching session. Use pyrepl'smemorytool 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 allowmcp__pyrepl__execthe same way: it runs anything.$SUDO_ASKPASSset to an askpass program in your environment, if you want root sessions orsudo()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)
pyreplis the client.pyrepl mcpis the MCP server Claude Code launches; the other subcommands are a CLI for you.pyrepld.pyis the daemon that owns the sessions. The client starts it on first use, on its own interpreter, as a transientsystemd --userunit namedpyrepld, withMemoryHigh=2G/MemoryMax=3Gcovering all sessions together (UNIT_PROPSinpyrepl). 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.pyis one session: a process holding one namespace, started by the firstexecunder a new name, in the caller's cwd and environment. A local session runs on$PYREPL_PYTHON, else the first of the config'spythonsthat runs, else the daemon's own python, unlesspythonnames 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.pyis the toolkit every session preloads;boot.pyandvendor/(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.
execwaitstimeoutseconds (default 30, max 600). If the code is still going, the call returns, the code keeps running, andwaitcollects 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 itsreprcomes back asOut[n]. Sessions run as a real__main__, so their functions pickle.input()gets EOF, andsys.exit()is caught; the session stays up. interruptraisesKeyboardInterruptin the running cell (it works even insidetime.sleep). If the cell is stuck in C code that never checks for signals,killthe session.- No persistence, no replay. A namespace lives exactly as long as its
process. Every cell is appended to
~/.local/state/pyrepl/<host>/<session>.pyfor 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
execunder that name opens with what was lost and where its code is.wait/interrupton 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, likeput) to~/.local/state/pyrepl/<host>/swap/and its process ends. The nextexecorsession("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 withinswap_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.sessionslists them,killdeletes one,pyrepl swap NAMEswaps one now, andpyrepl swapruns 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 abg()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
reprat 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.erris stderr, shown under[stderr];merge=Truefolds 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=Falsereturns the Result instead, withr.codeandr.ok: use it whenever non-zero is an answer rather than a failure (agrepwith no match,test -e,diff), instead of tacking; trueonto the command. - A string runs as
bash --norc -o pipefail -c.--norcmatters: under ssh, bash sources~/.bashrceven for-c, and a.bashrcthat 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 withinput=.shuses 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 (configsshandjump; 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 andsh()would wait on it), withexec /bin/sh -sas the only remote command, so h's login shell never parses the command; what its rc files print is dropped.cwd=andenv=apply over there;input=can't, since stdin carries the command. Agents should neversh("ssh h ...")or scp: with tcsh as the login shell bash syntax breaks, and it skips the configured route.sudo(cmd)issh()throughsudo -Awith$SUDO_ASKPASS, so nobody rolls their own prompt. Without$SUDO_ASKPASSit runssudo -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.
- 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. - Over stdin goes a short sh script (
REMOTE_SCRIPT) that looks for python in: the caller'spythons, the config'spythons, thenpython3.14...python3.6,python3,/usr/libexec/platform-python,python. Entries are paths, directories (python3.X,python3,pythoninside) 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. - It prints
PYREPL-BOOT, then execs that python on a small-cstub. 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)... andgit("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 andshow()diffs trees natively. status, diff, blame, config and writes go through git with-zmachine 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_pathand owns thepyreplname: nothing onsys.pathcan stand in for it (apyrepl/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, ...)importspyrepl.cadover 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'sjumpwins, andjump: []turns a configured one off.sudo:auto,nopasswdoraskpass(see Root sessions). Local root sessions use this machine's entry.ssh: the command and its options, replacing the defaults (-T, BatchMode, timeouts).-Jand the ControlMaster options are still added, so a non-OpenSSH command wants"multiplex": falseand nojump.swap_after_hours(24; 0 turns swapping off) andswap_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 everyexecsends 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 tosudo -Sand 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 sessionsshows each session's memory. A session killed by SIGKILL while the daemon runs under systemd most likely hit the sharedMemoryMax; kill idle sessions or raise the limit inUNIT_PROPS, thenpyrepl stop(it restarts on the next call).- After updating the code, run
pyrepl stopso 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 toworker.py,tk.pyandboot.pyalone 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. |