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, never built — there is no WebSocket stack, no TLS stack, no PTY bridge, and no remote proxy in the codebase.

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

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

Remote = ssh

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.

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

Completion events: no polling loop

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.

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.

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. POSTing a pickle is arbitrary code loading by design — 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, not a PTY.

Commands

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

Auto-checkpoint

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.

Security

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.

ModeAuth
Local POSIXAF_UNIX socket, mode 0o600 — filesystem permissions
Local Windows127.0.0.1 + bearer token in %LOCALAPPDATA%\pythond
Remotessh'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

PlatformTransportNotes
Linux / macOS / WSLHTTP over AF_UNIXfull featured
WindowsHTTP over 127.0.0.1 + tokenfull featured except fork (no COW fork)

Dependencies

None. Python standard library only.


Source · PyPI · MIT license