README.md

pyrepl #

macOS-support fork of https://tangled.org/poggers.website/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.

$ 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/alice.strange.domains/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.

Install by hand #

Requirements: Linux or macOS, an MCP client (or use the CLI directly), and Python ≥ 3.8 for the client and daemon (python3 on PATH; no third-party packages). Sessions themselves can run on any Python ≥ 3.6, e.g. a project venv.

git clone https://tangled.org/alice.strange.domains/pyrepl ~/tools/pyrepl
claude mcp add -s user pyrepl -- ~/tools/pyrepl/pyrepl mcp

Restart Claude Code; /mcp should list pyrepl with five 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:

Use the pyrepl MCP for running Python instead of python3 -c or throwaway scripts. Call sessions first and reuse a matching session.

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)
  • 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 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.

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

CLI:

pyrepl exec NAME 'code' [-t SECS] [--python PATH]   # 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.
  • 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.

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 on Linux, libc getpeereid on macOS), 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.

There is no network listener.

macOS #

The CLI and MCP server run with python3 from PATH, or an explicitly selected interpreter: python3 /path/to/pyrepl/pyrepl mcp. The daemon uses the client's interpreter; new sessions default to that interpreter unless PYREPL_PYTHON or --python selects another one. Python 3.6 compatibility applies to the worker, not the client/daemon.

On macOS the daemon starts as a detached process, not a systemd service or launchd job. There is no aggregate memory cap in this mode. pyrepl stop stops the daemon and discards all its sessions. Memory reporting uses /bin/ps. Both ends retain kernel-backed peer UID authentication; a credential lookup failure is an error, never a permission to trust the socket path.

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. That discards every live session.

Tests #

./test_pyrepl.sh
python3 -m unittest -v test_platform_support

Runs the CLI against a throwaway daemon (its own XDG_RUNTIME_DIR and XDG_STATE_HOME), so it doesn't touch your real sessions.

The shell suite accepts PYREPL_TEST_PYTHON=/path/to/python3. On macOS its descriptor-leak check uses /usr/sbin/lsof; realtime-signal coverage is explicitly skipped because those signals are unavailable. Platform unit tests also cover Linux selection and startup via mocks; they do not replace running the full suite on Linux.

History #

Adapted from house-repl, a single-namespace REPL server on TCP 127.0.0.1:9399. The main changes: one process per named session instead of one shared namespace; a uid-checked unix socket instead of an unauthenticated TCP port; every run starts synchronously with a timeout instead of guessing from the code text which runs are slow; captured output; real interrupts.