Persistent Python sessions. Code in, result out.
Variables, connections, and threads survive between calls. Agents send code through one-shot commands. Humans attach a REPL. Same namespace.
pip install pythond # zero dependencies
Python 3.10+. Three entry points:
| Command | Role | Like |
|---|---|---|
pythond | daemon | sshd |
pysh | session client | function call / attach |
pyctl | daemon control | systemctl |
ns = {}
while True:
code = receive()
exec(code, ns) # ns stays alive -- variables survive
send(captured_stdout)
Everything pythond adds is that loop plus delivery: thread-safe stdout capture, REPL semantics (the last expression auto-prints), one subprocess per named session, async cells, and local HTTP so one-shot CLI calls reach the live process. Transport is borrowed: ssh carries remote calls, a reverse proxy terminates TLS, your terminal runs attach.
pythond daemon pysh new work pysh run work "x = 42" pysh run work "x + 1" # 43 pysh attach work
pysh run work "import sqlite3; db = sqlite3.connect('app.db')"
# ... 100 turns later ...
pysh run work "db.execute('SELECT count(*) FROM users').fetchone()"
# (42,)
Connection ≠ state. Every call is a fresh connection to the same live process. The namespace is the workspace.
# fire: thread, shares namespace
pysh fire work "model = train(X, y)"
pysh poll work abc123
# {"cell_id":"abc123", "status":"done", "output":"..."}
pysh run work "model.score(X_test)" # model is there
# fork: child process (POSIX only), killable, pickles vars back
pysh fork work "results = expensive_search(params)"
pysh int work # SIGKILL the fork (POSIX)
For code with quotes, f-strings or SQL, post the file as the cell. The request body is raw Python source.
cat > /tmp/task.py << 'EOF'
import pandas as pd
df = pd.read_csv("data.csv")
print(f"rows: {len(df)}, cols: {list(df.columns)}")
EOF
pysh run work @/tmp/task.py
State lives in the remote daemon, so one-shot calls over ssh are enough:
ssh server pysh run work "x = 42" ssh server pysh run work "x + 1" # 43 (remote state) ssh -t server pysh attach work # interactive
ssh ControlMaster holds one connection open so each call skips
the handshake:
# ~/.ssh/config
Host server
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
For a TLS endpoint, put nginx or caddy in front of the loopback port.
pysh speaks plain HTTP over a local socket; so does curl:
curl --unix-socket $XDG_RUNTIME_DIR/pythond/pythond.sock \
--data-binary '1 + 1' http://pythond/run/work # 2
curl --unix-socket ... --data-binary @task.py http://pythond/run/work
GET /ls text listing POST /new/<name> 201 Created; 409 if name exists POST /new/<name>?replace=1 201 Created; explicitly discard and replace POST /run/<name> body=code 200 + raw output; X-Pythond-Exec-Error: 1 on traceback POST /fire/<name> body=code 202 Accepted; JSON receipt + Location: /poll/... POST /fork/<name> body=code 202 Accepted; JSON receipt + Location: /poll/... GET /poll/<name>[?cell=ID] 200 + JSON cell result GET /events 200 + SSE completions; Last-Event-ID for replay GET /status/<name> JSON health GET /vars/<name> JSON namespace names POST /complete/<name> body JSON completion matches POST /int/<name> JSON interrupt report GET /pickle/<name>[/<var>] pickled var (or whole picklable namespace) POST /pickle/<name>[/<var>] unpickle body into var (or merge a dict) POST /kill/<name> kill session POST /kill kill current sessions; JSON killed names + count POST /stop stop daemon
404 no such session, 409 name exists / session
busy / channel out of sync, 401 bad token. Send raw UTF-8 source
with a byte-count Content-Length; chunked request bodies get
411.
new returns 201, a text confirmation and
Location: /status/<name>. fire / fork
return a receipt:
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /poll/work?cell=abc123
X-Pythond-Session-Id: <worker id>
{"cell_id": "abc123", "status": "fired"}
Location is where the result appears
(RFC 9110
section 15.3.3). Python errors arrive in the poll result and in the
completion event. Every response carries X-Pythond-Protocol: 2.
Once a request has resolved its session, the response carries
X-Pythond-Session-Id, the id of the worker that handled it (for
kill, the worker that was removed); a 404 for a missing session
has none. A run that executed also carries
X-Pythond-Cell-Id.
pysh kill --all sends POST /kill, returning 200 JSON {"killed": ["work", "train"], "count": 2}; an empty daemon returns {"killed": [], "count": 0}. The operation snapshots worker instances, leaving later creations and same-name replacements alone. Each removed worker emits session_closed with reason killed. The daemon, token, epoch, SSE connections and checkpoint files remain. CLI requires exactly one name or --all.
One cell runs at a time per session. fire queues behind the
running cell. run, vars, complete,
/pickle and the fork snapshot return 409 busy right
away while a cell is running; the refused code is discarded and the session
stays healthy. status, poll and int work
during a running cell (status reports vars: null
meanwhile). One command is in flight per worker at a time; a second command
arriving meanwhile also gets 409 busy. run waits 30
seconds for the reply; a cell that runs longer keeps running, but the reply
timeout (like a malformed or oversized reply) leaves the channel out of sync,
and the way on is kill then new. Long work goes
through fire.
curl -N --unix-socket $XDG_RUNTIME_DIR/pythond/pythond.sock http://pythond/events
The worker pushes a frame over its pipe when a cell completes; the daemon
appends it to a replay log and wakes every subscriber. TCP uses the same bearer
token header as the other routes. A comment line every 15 seconds keeps the
connection alive. Each event has a JSON body and, except reset,
an id (<daemon-epoch>:<sequence>); the
retained ones (session_created, cell_done,
session_closed) also carry a timestamp (Unix seconds
at publication).
ready: the starting cursor, also in
X-Pythond-Event-Cursor. Subscribing without a cursor starts from now.session_created: session, session_id, pid. Sent once the worker
owns the name.cell_done: session, session_id, cell_id,
status: "done", error, output (last 64 KiB),
output_bytes, output_truncated, sync,
code_head (first 512 bytes of the source). sync is true
for run (correlate with X-Pythond-Cell-Id; the full
output is in the HTTP reply) and false for fire / fork
(full output at the receipt's Location). Fork adds merged/skipped
counts.session_closed: session, session_id, reason
(killed, replaced, exited).Reconnect with Last-Event-ID (or ?since=) to replay
the events after that cursor; order by cursor, deduplicate by id. A cursor from
another daemon epoch or from the future gets 409, an evicted one 410; a live
stream that falls behind gets event: reset with the current cursor
and closes. The log keeps 256 events / 8 MiB
(PYTHOND_MAX_EVENTS / PYTHOND_MAX_EVENT_BYTES) for the
daemon's lifetime; poll results stay for 300 seconds after completion. Closing a
subscription leaves Python running. A daemon restart starts a new epoch, a new
TCP token and an empty session table. Subscribers see every session;
code_head and output share the auth boundary of
execution.
run moves source code; /pickle moves live
objects — the fork merge-back mechanism, generalized. pysh cp
gives it scp syntax: a side is session:var, session:
(the whole picklable namespace), or a file path.
pysh cp work:df df.pkl # session -> file pysh cp df.pkl gpu:df # file -> session pysh cp work:model gpu:model # session -> session pysh cp work: backup: # clone the picklable namespace
Unpicklable values (sockets, locks, modules) are skipped and reported.
Unpickling runs code, so POSTing a pickle has the same trust boundary as
/run.
pysh attach work is a client-side line REPL: readline history
and tab completion live in the client, every complete block runs as one cell
in the shared namespace. Ctrl-D detaches; the session stays alive
(pysh kill ends it). It is line-oriented.
pysh new <name> create session (409 if the name exists) pysh new <name> --replace replace an existing session pysh run <name> "code" sync exec, raw output pysh run <name> @task.py post a file's contents as the cell pysh fire <name> "code" async thread, shares namespace (can't kill C code) pysh fork <name> "code" async process (POSIX), killable, pickles vars back pysh poll <name> [cell_id] check async result pysh int <name> interrupt (fire=best effort, fork=kill) pysh kill <name> terminate session pysh kill --all terminate current sessions, keep daemon running pysh ls list sessions pysh status <name> JSON health pysh vars <name> JSON namespace names pysh complete <name> "text" JSON completion candidates pysh attach <name> line REPL (Ctrl-D to detach) pysh cp <src> <dst> copy pickled objects (scp syntax) pyctl start [--show-token] start daemon in foreground pyctl stop stop daemon pyctl status daemon liveness
Successful synchronous cells and successful async completions are appended
to ~/.pythond/sessions/<name>/history.py. It holds source, so
replaying it recreates the state.
Treat pythond like SSH into a Python runtime. Code runs with the daemon user's OS permissions, and a connected client has full access to all sessions, the same as a login shell.
| Mode | Auth |
|---|---|
| Local POSIX | AF_UNIX socket, mode 0o600 — filesystem permissions |
| Local Windows | 127.0.0.1 + bearer token in %LOCALAPPDATA%\pythond |
| Remote | ssh |
The daemon binds only the unix socket or 127.0.0.1; ssh or a reverse proxy carries remote access.
Session names are lowercase only: a-z, 0-9,
underscore, and hyphen, up to 80 characters. Windows reserved device names
such as CON, NUL, COM1, and
LPT1 are rejected.
Like shell history under SSH, history.py holds executed source
and the live process holds assigned values until they are overwritten or the
session is killed. A secret pasted into a cell persists in both.
| Platform | Transport | Notes |
|---|---|---|
| Linux / macOS / WSL | HTTP over AF_UNIX | full featured |
| Windows | HTTP over 127.0.0.1 + token | everything except fork |
None. Python standard library only.