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, never built — there is no WebSocket stack, no TLS stack, no PTY bridge, and no remote proxy in the codebase.
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)
Complex code with quotes, f-strings, or SQL? Post the file as the cell. The request body is raw Python source — never JSON-escaped, never shell-quoted.
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, not in the connection, 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
Per-call latency? That is what ssh ControlMaster is for:
# ~/.ssh/config
Host server
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
Need a TLS endpoint anyway? Terminate it with nginx or caddy in front of the loopback port. pythond does not ship a TLS stack.
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 /stop stop daemon
404 no such session, 409 existing name or broken
session channel, 401 bad token. Send raw UTF-8 source with an
explicit byte-count Content-Length; chunked request bodies get
411.
new returns 201 Created, text confirmation, and
Location: /status/<name>. Async calls return receipts, not
execution results:
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /poll/work?cell=abc123
X-Pythond-Session-Id: <worker-incarnation>
{"cell_id": "abc123", "status": "fired"}
The Location header locates the status monitor without parsing
the body (RFC
9110 section 15.3.3). Acceptance is not execution success. Clients should accept
2xx, not only 200. run and kill retain their
200 text responses.
curl -N --unix-socket $XDG_RUNTIME_DIR/pythond/pythond.sock http://pythond/events
Subscribe before submitting work. Workers push completion frames to the
daemon; a condition notification wakes subscribers. The daemon does not scan
cells or call poll. TCP uses the same bearer-token header as other
routes. Fifteen-second SSE comments are connection heartbeats, not task polling.
ready establishes the initial event ID, also available in
X-Pythond-Event-Cursor. No cursor means future events only.cell_done carries JSON: session name, session_id
(worker incarnation), cell_id, status: "done", boolean
error, output, output_bytes, and
output_truncated. Fork adds merged/skipped counts.session_closed identifies the lost worker and reason:
killed, replaced, or exited.Reconnect with Last-Event-ID (or ?since=; the header
wins). Retained events after that ID are replayed, not consumed. IDs combine a
daemon epoch and sequence; deduplicate them. Invalid cursors return JSON 400,
a changed epoch or future cursor returns 409, an evicted cursor returns 410.
An open stream that falls behind receives event: reset and closes.
Replay is bounded to 256 events / 8 MiB by default, not durable or exactly-once.
Configure PYTHOND_MAX_EVENTS / PYTHOND_MAX_EVENT_BYTES.
Output snapshots contain at most the last 64 KiB of UTF-8 text; when truncated,
fetch the full result from the receipt's Location. Poll results
become eligible for eviction 300 seconds after completion, not launch. Replay
retention is independent of that TTL.
Disconnecting or reloading a client never cancels Python work. Daemon shutdown
closes streams; a restart loses sessions and changes the epoch and TCP token.
Adapters must persist their own cursor and job ownership, match the receipt's
X-Pythond-Session-Id plus cell ID, buffer completions that precede
the receipt, and deliver only to the owning conversation. Reconcile gaps with
poll where possible; never blindly resubmit code after a lost reply.
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.
POSTing a pickle is arbitrary code loading by design — 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, not a PTY.
pysh new <name> create; refuse an existing name pysh new <name> --replace explicitly discard existing state and replace 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 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. Async checkpointing
requires neither a subscriber nor a poll request. Errors are not
checkpointed. History contains source, not a saved copy of live connections.
Treat pythond like SSH into a Python runtime. Not a sandbox: code runs with the daemon user's OS permissions. Once connected, a 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's problem, on purpose |
The daemon never binds a non-loopback address. There is no network listener to harden.
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, pythond session history and live namespaces
can expose secrets. history.py may contain executed Python
source. Do not paste API keys, passwords, tokens, or other secrets into cells
unless you are willing for them to persist in that session and its local
files.
| Platform | Transport | Notes |
|---|---|---|
| Linux / macOS / WSL | HTTP over AF_UNIX | full featured |
| Windows | HTTP over 127.0.0.1 + token | full featured except fork (no COW fork) |
None. Python standard library only.