Skip to main content

pythond

sshd gives you a shell. pythond gives you Python.

Persistent Python daemon with named sessions, WebSocket protocol, and human attach. Connect to a live Python namespace — variables, connections, threads still running from last time.

pip install pythond

Quick start

# Daemon lifecycle is explicit.
pyctl start

# Session operations are explicit.
pysh new work
pysh run work "x = 42"
pysh run work "x + 1"          # → 43 (state persists)
pysh attach work               # human REPL; Ctrl-] detaches

Entry points

Command Role Analogy
pythond daemon process sshd
pysh session client function call / attach
pyctl daemon control systemctl

Commands

pysh new <name>              create session
pysh run <name> "code"       sync exec → raw output
pysh fire <name> "code"      async (thread) → shares namespace, can't kill C
pysh fork <name> "code"      async process (POSIX only) → killable, pickles vars back
pysh poll <name> [cell_id]   check async result
pysh int <name>              best-effort interrupt (fire=best effort, fork=kill)
pysh kill <name>             terminate session
pysh ls                      list sessions
pysh status <name>           session health (JSON)
pysh vars <name>             namespace names (JSON)
pysh complete <name> "text"  tab completion (JSON)
pysh attach <name>           human REPL (Ctrl-] to detach)

pyctl start [--listen HOST:PORT] [--tls]   start daemon in foreground
pyctl stop                                 stop daemon
pyctl status                               daemon info
pyctl connect <name> <host:port> <token> [--tls]   proxy to remote pythond
pyctl disconnect <name>                            drop remote proxy
pyctl cert                                 show/generate machine cert
pyctl trust <cert.pem>                     let this client connect (server-side)
pyctl pin <cert.pem>                       verify this server is real (client-side)

Session names are canonical lowercase: a-z, 0-9, _, or -, 1-80 characters. Names with uppercase letters or dots are rejected. Windows device names such as con, nul, prn, aux, com1, and lpt1 are also rejected.

Stateful First

Most command tools are intentionally stateless: fork, run, die. That is simple for humans, but wasteful for agents. Agents repeat imports, reopen connections, re-parse configs, and rebuild intermediate data because the process disappears after every call.

pythond flips that default. The process is the workspace. State is not a cleanup problem first; it is addressable memory.

Things that stay alive:

  • Python variables, imports, compiled regexes, parsed configs, DataFrames, and models,
  • database handles, HTTP sessions, WebSockets, TCP sockets, SSH tunnels, and browser/CDP sessions,
  • Flask apps, local servers, file watchers, monitors, and other daemon threads,
  • live control-plane decisions such as feature flags, rate limits, blocked IP sets, routing weights, and circuit breaker state.

Static config can become a Python variable. Patch one cell; the next request sees it. No restart is needed for logic that already lives inside the session.

Two Channels, One Namespace

The agent channel is structured: source code in, captured text/JSON out. It does not need to parse ANSI escape sequences, cursor movement, prompts, or screen redraws to know when a cell finished.

The human channel is interactive: pysh attach connects to the same process. On POSIX/WSL this is a real PTY byte stream. On native Windows it goes through WinPTY, which preserves execution and shared state but is not fully byte-transparent for readline screen redraws. A human can inspect variables, interrupt with Ctrl-C, or detach with Ctrl-] without discarding the namespace.

That split is the core design: pure data for agents, real terminal ergonomics for humans, one shared runtime underneath.

Two Control Surfaces

Use the interface that matches the job:

pysh run work "x = 1"     # agent / script: one-shot command, result out
pysh attach work        # human developer: interactive access to that session
pyctl status            # operator: daemon status

pyctl manages the daemon. pysh manages sessions. No command secretly starts the daemon, creates a session, and attaches in one step; failures stay in the right error domain.

pysh attach <name> is transparent on POSIX/WSL: once attached, you are looking at the actual Python >>> prompt, the same way tmux attach leaves you inside the real shell. Native Windows attach is a WinPTY console bridge, not the same raw PTY primitive; command execution and shared state work, but line-editing redraws such as history recall may not display identically. Use WSL when exact terminal behaviour matters.

Why pythond exists

pysh run work "x = 42"
pysh run work "x + 1"    # → 43

Code in, result out. Variables survive between calls. No terminal. No ANSI. No parsing. Function-call API to a persistent Python namespace.

AI agents use it as their Python runtime. Humans use pysh attach for an interactive REPL into the same namespace. Both see the same objects.

Why two daemons (remote proxy)

AI can't ssh. A human would ssh server then python -i — done. An AI agent can only do one-shot bash_tool calls, so it can't hold an SSH session open.

The two-daemon pattern solves this: the local daemon holds the connection the AI can't hold. pyctl connect tells the local daemon to proxy to a remote daemon. By default the proxy name is also the remote session name, so remote use looks local: pyctl connect work ... then pysh run work "code". For advanced routing, use explicit proxy form: pysh run server work "code".

fire vs fork

Both run code asynchronously. The difference is the execution model.

pysh fire work "model = train(data)"    # thread — shares namespace
pysh fork work "model = train(data)"    # process — killable, pickles back

fire (threading.Thread): Code runs in a thread that shares the session namespace. Exec is serialized (one cell at a time) — async to the client, not parallel. Variables set by fire'd code are immediately visible to later calls. Cannot be force-killed when stuck in C code (requests.get, time.sleep). pysh kill (whole session) is the escape.

fork (os.fork(), POSIX only): Code runs in a child process with a copy of the namespace. New/changed variables are pickled back and merged when done. pysh int kills it (SIGKILL). Unpicklable objects (sockets, locks, CUDA tensors) are skipped -- the poll response tells you what didn't come back. In-place mutations (list.append, dict[k]=v) won't merge -- use assignment (x = new_value). Failed forks do not merge. Merge is last-writer-wins: a completed fork may overwrite variables changed in the parent while running.

// poll after fork completes
{"cell_id": "abc", "status": "done", "output": "...",
 "merged": ["model", "results"], "skipped": ["db_conn"]}

Protocol

WebSocket with newline-separated fields. Python code is never JSON-escaped.

ws.send("run work 1 + 1")               → "2"
ws.send("run work\nprint('hello')")     → "hello"
ws.send("fire work train(epochs=50)")   → {"cell_id":"..."}
ws.send("fork work\ntrain(epochs=50)")  → {"cell_id":"..."}
ws.send("ls")                           → "  work: alive pid=123"

Use inline form for simple one-line code: run work 1 + 1. Use body form when the code is multiline: run work\n<python source>.

The protocol supports multiple commands on one WebSocket. The normal pysh CLI opens a short connection per command; pyctl connect keeps a remote proxy connection alive inside the local daemon.

Raw WebSocket clients are useful for protocol debugging. They are not the main user interface; pysh and pyctl handle tokens, pinning, and local metadata.

# Example only: replace the token with your daemon token from daemon.json or --show-token.
# Localhost only. Remote access should use wss:// with certificate pinning.
npx wscat -c ws://127.0.0.1:7984 \
  -s pythond.0.4 \
  -H "Authorization: Bearer b50cdd77873efb989f9199a4e245b911"

wscat shows > for frames you send and < for frames from the daemon, which is useful when checking request/response direction. Once connected, send protocol messages directly, without a pysh prefix:

ls
new work
run work
1 + 1

WebSocket is message-framed, not a stream REPL. Empty messages are ignored; the daemon does not emit a fake prompt. Some tools display received frames without preserving visual newlines exactly; use wscat for a readable manual protocol check, or websocat --jsonline when you need machine-readable frame boundaries. Normal users should use pysh/pyctl.

Transport

Mode URL Auth Use case
Local POSIX ws:// over AF_UNIX socket perms default
Local Windows ws://127.0.0.1:7984 token default
Remote wss://host:7984 token plus pinned self-signed server cert; optionally mTLS non-loopback --listen auto-enables TLS

Remote access

# Server
pip install pythond
pyctl start --listen 0.0.0.0:7984 --show-token
# Non-loopback --listen auto-enables TLS.
# prints token and fingerprint

# Client: copy server ~/.pythond/tls/cert.pem to client as ~/server_cert.pem.
# Remote TLS uses a self-signed cert, so pin before connecting.
pyctl pin ~/server_cert.pem
export PYTHOND_HOST=10.0.0.5:7984 PYTHOND_TOKEN=abc... PYTHOND_TLS=1
pysh new work
pysh run work "import platform; platform.node()"

mTLS plus token

# Client: generate client cert
pyctl cert
# copy client ~/.pythond/tls/cert.pem to server as ~/client_cert.pem

# Server: generate server cert, trust client cert
pyctl cert
pyctl trust ~/client_cert.pem
# copy server ~/.pythond/tls/cert.pem to client as ~/server_cert.pem
pyctl start --listen 0.0.0.0:7984 --show-token
# Non-loopback --listen auto-enables TLS.
# cert is required and token is still required

# Client: pin server cert, then connect (client cert sent automatically)
pyctl pin ~/server_cert.pem
export PYTHOND_HOST=10.0.0.5:7984 PYTHOND_TOKEN=<printed-token> PYTHOND_TLS=1
pysh run work "x"

SSH tunnel

ssh -L 7984:localhost:7984 user@server "pythond daemon --listen 127.0.0.1:7984 --show-token"
# local:
export PYTHOND_HOST=127.0.0.1:7984 PYTHOND_TOKEN=<printed-token>
pysh run work "x"

Remote proxy

Local daemon maintains connection to remote daemon. Agent just talks to local.

pythond daemon                                    # local daemon
pyctl connect work 10.0.0.5:7984 <token> --tls    # proxy alias = remote session
pysh run work "x = 42"                            # forwarded to remote work
pysh run work "x"                                 # → 42 (remote state)
pyctl disconnect work

One local proxy can also address a different remote session explicitly:

pyctl connect server 10.0.0.5:7984 <token> --tls  # proxy alias
pysh run server gpu "x = 42"                      # remote session = gpu

Auto-checkpoint

Successful synchronous run cells are saved to ~/.pythond/sessions/<name>/history.py. Successful async fire/fork cells are saved when poll observes completion. Errors go to session.log but not history.py.

Like shell history and environment variables under SSH, pythond session history, logs, and live namespaces can expose secrets. history.py and session.log may contain executed Python source and captured output. Variables assigned in a session remain in that live Python process until overwritten or the session is killed. 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.

# Process died? Replay:
pysh new work
pysh run work "exec(open(os.path.expanduser('~/.pythond/sessions/work/history.py')).read())"

Security

The security model mirrors SSH:

pythond SSH equivalent
token in daemon.json private key in ~/.ssh/
pyctl trust cert.pem adding a line to authorized_keys
pyctl pin cert.pem adding a line to known_hosts
authenticated client logged-in user

Once authenticated, a client has full access to all sessions — there is no per-session permission isolation. This is the same as SSH: once you log in, you are that user with all their permissions.

  • Not a sandbox: code runs with the daemon user's OS permissions
  • Local POSIX: AF_UNIX socket with 0o600 permissions
  • Local Windows: OWNER RIGHTS DACL via icacls — owner-level isolation (comparable to Unix chmod 700)
  • Remote: pinned self-signed TLS cert + token auth, with optional mTLS client cert
  • Access logs: daemon writes ACCESS lines to runtime access.log and mirrors them to daemon stderr for supervisors; logs include conn_id, peer, cmd, session, status, and body_bytes, but never token or code body
  • Crash isolation: 5-layer try/except + process isolation — exec errors never kill daemon

Operations

Run the daemon in the foreground under your supervisor:

pythond daemon
# or
pyctl start --listen 0.0.0.0:7984
# non-loopback --listen auto-enables TLS

Operational signals:

pyctl status          # daemon endpoint metadata and liveness
pysh ls               # sessions known to the daemon
pysh status work      # one session's worker health
pyctl stop            # graceful daemon shutdown

Environment knobs:

Variable Default Purpose
PYTHOND_HOST unset Client target, HOST[:PORT], for remote or tunneled daemons
PYTHOND_TOKEN from daemon metadata when local TCP Client auth token override
PYTHOND_TLS unset Set 1, true, or yes for client wss://
PYTHOND_PORT 7984 Default TCP port for daemon/client when no port is explicit
PYTHOND_SOCK safe runtime path POSIX AF_UNIX socket path override
PYTHOND_MAX_SESSIONS 128 Maximum live sessions per daemon
PYTHOND_MAX_WS_PAYLOAD 16777216 Maximum WebSocket message payload, bytes
PYTHOND_MAX_WORKER_RESPONSE 16777216 Maximum single worker response line, bytes
PYTHOND_MAX_TLS_BRIDGE_THREADS 256 Maximum concurrent TLS bridge threads
PYTHOND_TLS_BRIDGE_IO_TIMEOUT 30 TLS bridge I/O timeout, seconds

PYTHOND_INTERNAL_WORKER is reserved for daemon-spawned workers; do not set it manually.

Logs:

  • ACCESS ... lines are mirrored to daemon stderr for systemd/supervisor/journald.
  • The same access events are appended to the runtime access.log.
  • Per-session activity goes to ~/.pythond/sessions/<name>/session.log.
  • Successful replayable sync execs go to ~/.pythond/sessions/<name>/history.py.
  • Successful async execs go there when poll observes completion.

Access logs are for daemon operations: connection id, peer, cmd, session, status, and body size. They deliberately do not record tokens or Python source. Use session.log when you need the executed code and output.

Runtime files and durable state live in different places:

Purpose Windows POSIX
daemon metadata/logs %LOCALAPPDATA%\pythond\daemon.json, %LOCALAPPDATA%\pythond\access.log $XDG_RUNTIME_DIR/pythond/ or /tmp/pythond-$UID/
session state/certs ~\.pythond\sessions\..., ~\.pythond\tls\... ~/.pythond/sessions/..., ~/.pythond/tls/...

Cross-platform

Platform PTY Transport Notes
Linux/macOS pty.openpty() AF_UNIX WS full featured
Windows pywinpty TCP WS execution/state supported; not raw byte-transparent for readline redraws
WSL same as Linux AF_UNIX WS full featured

Architecture

agent (one-shot bash_tool)
  ↓ ws://unix socket or wss://tcp
daemon process (WebSocket server, keep-alive connections)
  ├── session "work" (subprocess, isolated)
  │     ├── persistent namespace (variables live forever)
  │     ├── AI channel: JSON lines over socketpair
  │     └── human channel: POSIX PTY or Windows WinPTY bridge
  ├── session "gpu" (another subprocess)
  └── remote "server" (WebSocket proxy to remote daemon)

Design

exec() is the core insight. Old agent-terminal tools parse ANSI escape sequences from TTY byte streams to detect when commands finish. pythond uses exec(code, namespace) — source code in, captured output out, function call semantics. No ANSI parsing. No frame detection.

Connection ≠ state. SSH conflates them — disconnect kills the shell. pythond separates them — the WebSocket is transport, the namespace is state. Disconnect and reconnect; state survives.

Write-file-then-exec. Complex code with quotes and f-strings? Write a file, then exec(open('/tmp/task.py').read()). The file is transport; the namespace is the workspace.

REPL Patterns

Because the session is a Python REPL, ordinary Python patterns become agent operations:

  • Prefix tax: import what you use once, then call shorter names in later cells.
  • Print tax: expression results display automatically; the last expression does not need print().
  • Hot reload: use exec(open("module.py").read()) or importlib.reload(m) to update code without losing process state.
  • Incremental execution: split a long script into cells. If step 3 fails, fix step 3; steps 1 and 2 still exist in memory.
  • Catch, fix, retry: read the traceback, patch a function, and run again in the same namespace.
  • Host commands: use subprocess.run(..., capture_output=True, text=True) from inside the session when you need the OS.

Persistent subprocess

The session can host long-lived child processes. A persistent bash inside the persistent Python REPL gives you shell state (cd, env vars, aliases) that survives across agent turns:

pysh run work "
from subprocess import Popen, PIPE, STDOUT
import queue, threading, time

shell = Popen(['bash'], stdin=PIPE, stdout=PIPE, stderr=STDOUT, text=True, bufsize=1)
_q = queue.Queue()
threading.Thread(target=lambda: [_q.put(l) for l in shell.stdout], daemon=True).start()

def sh(cmd, timeout=5):
    marker = f'__DONE_{time.monotonic_ns()}__'
    shell.stdin.write(f'{cmd}\necho {marker}\n')
    shell.stdin.flush()
    lines, deadline = [], time.monotonic() + timeout
    while time.monotonic() < deadline:
        try:
            line = _q.get(timeout=0.1)
            if line is None or marker in line: break
            lines.append(line.rstrip())
        except queue.Empty: continue
    return chr(10).join(lines)
"

Now bash state persists:

pysh run work "print(sh('cd /tmp && pwd'))"
# /tmp

pysh run work "print(sh('pwd'))"
# /tmp  ← cd persisted

pysh run work "print(sh('export SECRET=hunter2'))"
pysh run work "print(sh('echo \$SECRET'))"
# hunter2  ← env var persisted

The same pattern works for many interactive subprocesses: node, redis-cli, psql. The Python session is the host; everything else lives inside it. For commands that need a real tty, use pysh attach instead.

Tests

Static syntax check:

python -B -m py_compile pythond.py test_pythond.py

Run test suite:

python -B test_pythond.py

Dependencies

pythond              websockets, wsproto, cryptography, pywinpty (Windows only)

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pythond-0.4.0.tar.gz (89.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pythond-0.4.0-py3-none-any.whl (51.1 kB view details)

Uploaded Python 3

File details

Details for the file pythond-0.4.0.tar.gz.

File metadata

  • Download URL: pythond-0.4.0.tar.gz
  • Upload date:
  • Size: 89.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pythond-0.4.0.tar.gz
Algorithm Hash digest
SHA256 25b6f78690550296d2eb4226a8a7a5a725ee8ead469d94c8900cfed0754ab343
MD5 82c8c0f11a603522d34802d98e2cef7d
BLAKE2b-256 62f2042de3e0b9d501d73a720927333736a8814c0de96d0197fc14c51b8e2957

See more details on using hashes here.

Provenance

The following attestation bundles were made for pythond-0.4.0.tar.gz:

Publisher: release.yml on rangersui/pythond

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pythond-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: pythond-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 51.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for pythond-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ffeaa361b792c4bc9866e7fcb58a31de0cd9dc09a105c7373c3b2e45e3a7acff
MD5 caa863315ede059adb71e695060435f5
BLAKE2b-256 43b9fed0d8e961c3200e4e1c702bf6dc1dd1b754c4785786d0168460ba91c2eb

See more details on using hashes here.

Provenance

The following attestation bundles were made for pythond-0.4.0-py3-none-any.whl:

Publisher: release.yml on rangersui/pythond

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page