README
¶
pupptyeer
A small, local PTY session-manager daemon: it owns persistent pseudo-terminal sessions, lets multiple clients (a human terminal and a programmatic driver) attach to the same live session, and ships with a CLI and an MCP server to drive them. Generic and program-agnostic: anything specific to one agent or app (TUI driving, transcript lifting, web UI) is meant to be built on top.
Status: v0, initial implementation. Builds, tested end-to-end on Linux (Go drive test + TS/Python client smoke tests). macOS and Windows are experimental (see Parity & platforms). Canonical wire spec:
PROTOCOL.md.
Why
A persistent, agent-agnostic substrate so an app can: start a long-lived process in a PTY, drive it programmatically, stream its raw output to a browser terminal (xterm.js), and let a human and an automated driver share the same live session. Built fresh to prioritise cross-language ergonomics
- batteries-included over raw throughput.
Install
Three ways to get the pupptyeer (daemon + CLI) and pupptyeer-mcp binaries, easiest first:
# 1) prebuilt, via npm (resolves your OS/arch automatically)
npm i -g @petersr/pupptyeer
# 2) prebuilt, from GitHub Releases - download the archive for your platform and extract:
# https://github.com/PeterSR/pupptyeer/releases/latest
# pupptyeer_<version>_<os>_<arch>.tar.gz (zip on Windows) - each bundles both binaries.
# 3) from source (needs Go) - see "Build from source" below.
The client libraries are published too: npm i pupptyeer-client and pip install pupptyeer-client
(see Clients).
Build from source
make build # both binaries into bin/ (or the two `go build` lines below)
make install # install pupptyeer + pupptyeer-mcp into your go bin
go build -o bin/pupptyeer ./cmd/pupptyeer # daemon + CLI
go build -C mcp -o ../bin/pupptyeer-mcp . # MCP front-end (separate module)
# start the daemon in the foreground (unix socket; override path with $PUPPTYEER_SOCK)
./bin/pupptyeer daemon &
# or install it as a per-user managed service that starts at login
# (systemd --user on Linux, a launchd LaunchAgent on macOS, a Windows service)
pupptyeer daemon install # install + start; auto-starts at login
pupptyeer daemon status # not installed | installed, stopped | running
pupptyeer daemon start | stop | restart # manage the running service
pupptyeer daemon uninstall # stop + remove
# drive it
./bin/pupptyeer ctl new bash # -> <session-id>
./bin/pupptyeer ctl new --raw bash # raw/fast session: no terminal emulator (no rendered capture; lower CPU/latency)
./bin/pupptyeer ctl new --cwd ~/src/app bash # start the child in a directory of your choosing (relative paths resolve against your shell)
./bin/pupptyeer ctl new --copy-env --env TERM=xterm-256color bash # hand the child this shell's environment, with overrides
./bin/pupptyeer ctl list
./bin/pupptyeer ctl send <id> $'echo hi\n'
./bin/pupptyeer ctl capture <id> # raw scrollback bytes (ANSI included)
./bin/pupptyeer ctl capture --render <id> # rendered visible screen (escape codes applied, one line per row)
./bin/pupptyeer ctl capture --render --settle 200 <id> # wait for the screen to go quiet (200ms), then render
./bin/pupptyeer ctl attach <id> # interactive: stdin forwarded, resize propagated, Ctrl-\ detaches
./bin/pupptyeer ctl attach -r <id> # read-only output stream (auto when stdin isn't a tty)
./bin/pupptyeer ctl expect --regex 'DONE' --timeout 10m <id> # block (no polling) until output matches, then exit 0
./bin/pupptyeer ctl expect --idle 3s <id> # ...or until output goes quiet for 3s
./bin/pupptyeer ctl expect --exit <id> # ...or until the child process exits (prints its code)
./bin/pupptyeer ctl expect --regex 'ERROR' --follow --strip-ansi <id> # one line per hit; wrap in a watcher
./bin/pupptyeer ctl kill <id>
./bin/pupptyeer ctl steal 12345 # Unix only: start a session that runs `reptyr 12345`, then attach to it
./bin/pupptyeer ctl steal -T --id lifted 12345 # Unix only: steal the whole tty/process group with a chosen session id
./bin/pupptyeer ctl gc --max-idle 1h # reap sessions idle (no PTY I/O) for >= 1h; --max-idle 0 reaps all
./bin/pupptyeer ctl -n myapp new bash # -n <ns> scopes a subcommand to a namespace (default "default")
./bin/pupptyeer ctl list --all-namespaces # list (or gc) across every namespace
# MCP server (separate binary; talks to the daemon over the socket).
# stdio by default:
./bin/pupptyeer-mcp
# or Streamable HTTP, with optional auth (none | token | oauth):
./bin/pupptyeer-mcp -transport http -auth none
./bin/pupptyeer-mcp -transport http -auth token -token "$PUPPTYEER_MCP_TOKEN"
./bin/pupptyeer-mcp -transport http -auth oauth \
-oauth-issuer https://issuer.example.com -oauth-audience http://127.0.0.1:8765
Socket path resolution: $PUPPTYEER_SOCK → $XDG_RUNTIME_DIR/pupptyeer/daemon.sock →
$TMPDIR/pupptyeer-<user>/daemon.sock, where <user> is the numeric uid on Unix and the user SID
on Windows (so the default dir is per-user on every platform). Local only: on Unix the socket dir is
mode 0700 and the socket 0600; on Windows, where mode bits are ignored, the daemon installs a
DACL restricting the socket dir to the current user.
pupptyeer daemon install registers the daemon as a per-user service (via
kardianos/service: systemd --user, a launchd LaunchAgent,
or a Windows service) that starts at login and restarts with exponential backoff if it exits, so the
socket is always there for the CLI, the MCP server, and the language clients. It runs as you (not
root), keeping the per-user, local-only socket model intact. Any PUPPTYEER_SOCK / PUPPTYEER_CONFIG
set in the shell at install time is baked into the service environment so the service and your CLI
resolve the same socket; change either and re-run install. Linux is verified (on systemd the
install also wires the unit to default.target so it starts at login, and adds a restart-backoff
drop-in capped at five minutes); macOS (LaunchAgent) and Windows (service) ride the same command but
are experimental, like the rest of the cross-platform surface.
What a session inherits
A new session's child process inherits the daemon's environment and working directory, not your
shell's. Under daemon install that daemon is a per-user service, so its environment is a service
environment: TERM=dumb, and a PATH without the things a login shell adds (~/go/bin,
~/.local/bin, a version-manager's node). A TUI started that way renders as best it can, and
anything the child shells out to - a git hook, gh, node - can fail with command not found deep
inside the child, far from the cause.
ctl new can set both explicitly, over the socket:
| flag | effect |
|---|---|
--cwd <dir> |
working directory for the child; relative paths resolve against your shell, not the daemon |
--copy-env |
start from this shell's environment |
--clean-env (-i) |
start from an empty environment |
--env NAME=VALUE |
set one variable (repeatable) |
--env-file <file> |
set variables from a file of NAME=VALUE lines (# comments and blank lines ignored; values verbatim, no quote stripping or expansion) |
They apply in the order given, so --copy-env --env TERM=xterm-256color means "this shell's
environment, but with that TERM". The wire carries either a complete environment or none, so there
is no way to add to the daemon's: --env and --env-file therefore require --copy-env or
--clean-env to say what to start from, rather than silently handing the child a one-variable
environment. With no environment flags at all, the child inherits the daemon's, as it always has.
Passing the environment over the socket also keeps it out of the child's argv, where the
env -i NAME=VALUE... command workaround would leave it for any local user to read in ps.
A session that has a cwd shows it in ctl list.
Configuration (optional)
The CLI reads an optional TOML config file, resolved in order: $PUPPTYEER_CONFIG,
else $XDG_CONFIG_HOME/pupptyeer/config.toml (honoured on every platform), else the OS-native user
config dir (~/.config on Linux, ~/Library/Application Support on macOS, %AppData% on Windows).
It is purely client-side, so it affects only ctl; the daemon and wire protocol are untouched. A
missing file is fine; a malformed one (or an unknown key) is reported with the path and line.
Standard TOML applies: string values are quoted, and inline # comments are allowed.
# detach key for interactive attach (default: ctrl-\, like dtach).
# accepts ctrl-X / c-X / ^X, a hex byte like 0x1c, or "none" to disable
# (leaving SIGINT/SIGTERM as the only way out). use single quotes so the
# trailing backslash is taken literally.
detach_key = 'ctrl-]'
# optional tmux-style prefix: detach becomes a two-key sequence,
# detach_prefix then detach_key. uncomment both for "Ctrl-b then d" like
# tmux. the prefix must be a control key; with one set detach_key may be
# any single key (e.g. d). the prefix is held and only forwarded to the
# session if the next key isn't detach_key, so a prefix you also use
# inside the session still works.
# detach_prefix = 'ctrl-b'
# detach_key = 'd'
# default size for `ctl new` when no explicit size is given (default 80x24)
default_cols = 120
default_rows = 40
# suppress the "[pupptyeer: attached ...]" banner on attach (default false)
quiet = false
Clients
- Go:
clients/go(its own zero-dependency module:import client "github.com/PeterSR/pupptyeer/clients/go") - TypeScript / Node:
npm i pupptyeer-client(source inclients/typescript; zero deps) - Python:
pip install pupptyeer-client(source inclients/python; stdlib only)
Prefer not to build the Go binary yourself? Grab a prebuilt one via npm i -g @petersr/pupptyeer or
straight from GitHub Releases (see
Install). PUBLISHING.md documents how every artifact is released.
Protocol
NDJSON over a unix socket: one JSON object per line, raw PTY bytes base64 in data. Verbs:
new_session, list_sessions, attach/detach, write_pane, capture_pane, resize, kill,
gc (reap sessions idle past a threshold). Full spec in PROTOCOL.md.
A session is addressed by (namespace, id). Every session-addressed verb carries an optional
namespace (omitted ⇒ "default", so old clients are unaffected); ids are unique within a namespace
but may repeat across them, giving per-app isolation inside one daemon. list_sessions/gc scope to
a namespace or, with all: true, span every namespace. Clients set a connection-default namespace at
connect (kubectl-context style) and can override it per call; in ctl use -n <ns> per subcommand
and --all-namespaces on list/gc.
capture_pane has two modes. By default it returns raw scrollback bytes. With render, the daemon
maintains one live terminal emulator per session and returns the rendered visible screen - the
authoritative grid of cols×rows, the cursor, and whether the program is on the alternate screen
buffer - so clients never have to embed their own emulator to read a TUI. Either mode accepts
settle_ms to hold the reply until the PTY has been quiet for that long (the reliable way to read a
screen after sending input). Rendering reports what is on the screen, never what it means; any
interpretation belongs in the layer above.
Waiting on a session (ctl expect)
ctl expect blocks until something happens on a session and then exits - no capture polling loop.
It rides the daemon's push-based attach stream, so the daemon does the watching. Arm any combination
of triggers; the first to fire wins:
--regex <re>/--substr <s>- the pattern appears in the output (matched across chunk boundaries; add--strip-ansito match plain text through a TUI's escape sequences),--idle <dur>- output goes quiet for that long (quiescence),--exit- the child process exits (its code is printed),--timeout <dur>- an overall deadline.
Each fire prints one line (match regex="DONE" off=12, idle 3s, exit code=7); the exit code is
the machine-readable outcome: 0 a trigger fired, 2 timed out, 3 the session closed first.
Matching is armed for live output only - a stale marker already in the scrollback won't trigger it
(pass --include-scrollback to also scan the replay). --follow keeps reporting, one line per hit,
until the session closes or --timeout.
This is the poll-free way to let one process wait on another - e.g. an agent driving a second agent
in a PTY. One-shot (default) exits the moment the trigger fires, so backgrounding the single call is
itself the "wake when the inner process is done" signal - no polling loop; --follow is what you
wrap in an event watcher to wake on each hit. For a
program with an animated TUI (a spinner never goes quiet), trigger on a sentinel --regex the inner
program prints; --idle/--exit suit line-oriented or headless inner processes. It is a CLI
convenience built entirely on the attach verb, so it adds no wire surface. Smoke:
bash cmd/pupptyeer/expect_smoke.sh.
Fast path (opt-in)
The defaults optimise for cross-language ergonomics (one JSON shape, three clients in lockstep) over raw speed, and run a terminal emulator per session to power rendered capture. Two opt-ins shed that overhead for throughput- or latency-sensitive consumers, leaving the default protocol untouched:
- Raw sessions -
new_sessionwithraw:true(orctl new --raw) run no terminal emulator: lower CPU and latency, at the cost of rendered capture (raw scrollback capture still works). - Raw firehose - an optional second socket at
<sock>.raw: a transparent, unframed byte pipe to one session's PTY (no base64, no JSON, no framing). Reachable from any language, or fromsocat/nc, with no client library; the Go client wraps it asAttachRaw. It is out of band and out of the parity matrix (like the MCP tools), so the default socket and protocol are unchanged.
Combined, the fast path streams raw bytes at parity with a lean binary PTY transport (measured around
320 MiB/s and a ~30µs echo round-trip on Linux), while the default NDJSON path keeps the rich
features. See PROTOCOL.md for the handshake.
MCP server
pupptyeer-mcp is a separate binary and Go module that exposes the daemon's verbs as MCP tools. It
depends only on the Go client and reaches the daemon over the socket, so the daemon never inherits
the MCP, HTTP, or OAuth dependencies. Its read_screen tool returns the rendered visible grid by
default (set render=false for raw scrollback) and accepts settle_ms, so an agent can send input
and then read a settled screen. It serves stdio (the default) or Streamable HTTP. The
HTTP transport offers three auth modes: loopback-only with no token, a static bearer token
(-auth token), or an OAuth 2.1 resource server (-auth oauth) that validates an external
identity provider's bearer JWTs and publishes RFC 9728 protected-resource metadata.
Layout
cmd/pupptyeer daemon | ctl | version
mcp/ pupptyeer-mcp: MCP stdio/http front-end (separate module; deps the Go client)
internal/protocol NDJSON message type + codec
internal/ptyx cross-platform PTY (creack on Unix, ConPTY on Windows)
internal/server daemon: registry, connections, sessions, ring buffer, fan-out
clients/go Go client library (separate zero-dep module)
clients/{typescript,python} thin clients (parity matrix: clients/PARITY.md)
conformance/ scenario + per-language runners + run.sh (cross-client matrix)
PROTOCOL.md canonical wire contract (source of truth)
CLAUDE.md working guide + parity rule
Parity & platforms
The daemon and the Go/TS/Python clients move together: bash conformance/run.sh runs the same
scenario through all three against one daemon (the /check-parity skill bundles this). The PTY
layer is cross-platform (creack on Unix, ConPTY on Windows); windows/amd64, windows/arm64, and
darwin/arm64 cross-compile cleanly.
Linux is the tested, supported platform. macOS and Windows are both experimental: they
cross-compile cleanly but have not been run on real hardware. macOS rides the same Unix path
(creack, SIGWINCH, 0700/0600 socket perms), so it is expected to work but is unverified. Windows
has its platform-specific pieces in place (ConPTY, polled resize, per-user socket dir + DACL), plus
extra caveats: SIGTERM is not deliverable (use Ctrl-C / SIGINT), ANSI rendering assumes a
VT-capable console (Windows Terminal, or ENABLE_VIRTUAL_TERMINAL_PROCESSING), and the socket-dir
DACL fails closed (the daemon refuses to start if it cannot be applied). Verify on real hardware
before relying on either.
Related projects
pupptyeer is a PTY session manager in the tmux/screen lineage, built MCP-native from day one, on the bet that machine-driven terminals are here to stay, so a protocol for them belongs in the core rather than bolted on later. It stays general-purpose: a human shell, a script in any of the three client languages, or an AI tool over MCP are all first-class ways to drive the same session. That places it between two camps, and depending on what you're after, something in either may serve you better:
Classic session managers (general-purpose, predate MCP):
- zmux: daemon-owned PTYs with attach/detach over unix JSON-RPC and server-side scrollback. tmux-like window/pane model. Zig; macOS + Linux; no MCP.
- tmux / screen: the originals; battle-tested multiplexers, but no machine-native protocol.
MCP-first, agent-focused tools (MCP-native, built around AI agents):
- Forge: persistent
node-ptydaemon with a built-in MCP server, ring buffer with incremental reads, multi-agent fan-out, and a browser dashboard. Node, over HTTP. - pty-mcp + ai-tmux: MCP server backed by an
ai-tmuxdaemon that keeps PTYs alive over a unix socket. Single Go binary; macOS + Linux, Windows via WSL2.
If AI agents are front and center for you (orchestrating fleets of them, spawning sub-agents, watching them in a dashboard), the agent-focused tools above are the better bet; that is what they are built for. In pupptyeer an agent is simply one first-class client among several: the MCP server sits beside the CLI and the language clients, none privileged over the others.
pupptyeer's own niche is the diagonal between the two: a generic, agent-agnostic session manager over a local unix socket, with a built-in MCP server and a CLI and Go, TypeScript, and Python clients held in lockstep protocol parity. If you want one session substrate that is equally at home behind a shell, a script, and an AI tool, that is the gap it fills.
Tests
go test ./... # drive test (attach/detach/replay), resize arbitration, protocol round-trip
License
MIT.
Directories
¶
| Path | Synopsis |
|---|---|
|
clients
|
|
|
go
module
|
|
|
cmd
|
|
|
pupptyeer
command
Command pupptyeer is a local daemon that owns persistent PTY sessions, plus a CLI to drive them.
|
Command pupptyeer is a local daemon that owns persistent PTY sessions, plus a CLI to drive them. |
|
conformance
|
|
|
go
command
Conformance runner (Go) - implements conformance/scenario.md.
|
Conformance runner (Go) - implements conformance/scenario.md. |
|
internal
|
|
|
protocol
Package protocol defines the NDJSON wire format spoken between the pupptyeer daemon and its clients over a unix socket.
|
Package protocol defines the NDJSON wire format spoken between the pupptyeer daemon and its clients over a unix socket. |
|
ptyx
Package ptyx is a thin cross-platform pseudo-terminal abstraction.
|
Package ptyx is a thin cross-platform pseudo-terminal abstraction. |
|
server
Package server runs the pupptyeer daemon: a unix-socket listener, a process-wide session registry, and per-connection NDJSON multiplexing.
|
Package server runs the pupptyeer daemon: a unix-socket listener, a process-wide session registry, and per-connection NDJSON multiplexing. |
|
pkg
|
|
|
ptysession
Package ptysession is the program-agnostic core of pupptyeer: one supervised pseudo-terminal with a ring buffer of scrollback, an optional live terminal emulator for rendered-screen capture, settle-aware capture, resize, and clean teardown with an exit code.
|
Package ptysession is the program-agnostic core of pupptyeer: one supervised pseudo-terminal with a ring buffer of scrollback, an optional live terminal emulator for rendered-screen capture, settle-aware capture, resize, and clean teardown with an exit code. |