pythond

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:

CommandRoleLike
pythonddaemonsshd
pyshsession clientfunction call / attach
pyctldaemon controlsystemctl

The whole idea

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.

Quick start

pythond daemon
pysh new work
pysh run work "x = 42"
pysh run work "x + 1"
# 43
pysh attach work

State persists

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.

Async execution

# 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)

Post a file

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

Remote = ssh

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.

HTTP API

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.

Kill all

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.

Busy

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.

Completion events

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

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.

Objects move as pickles

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.

Attach

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.

Commands

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

Auto-checkpoint

Successful synchronous cells and successful async completions are appended to ~/.pythond/sessions/<name>/history.py. It holds source, so replaying it recreates the state.

Security

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.

ModeAuth
Local POSIXAF_UNIX socket, mode 0o600 — filesystem permissions
Local Windows127.0.0.1 + bearer token in %LOCALAPPDATA%\pythond
Remotessh

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

PlatformTransportNotes
Linux / macOS / WSLHTTP over AF_UNIXfull featured
WindowsHTTP over 127.0.0.1 + tokeneverything except fork

Dependencies

None. Python standard library only.


Source · PyPI · MIT license