Beta — the CLI and testing library are usable but the API may change before 1.0.
Persistent terminal sessions. Run a process, detach, reconnect later. From anywhere, locally and over SSH.
Uses @xterm/headless internally.
npm install -g @myobie/ptyOr with Nix:
nix profile install github:myobie/pty # install the CLI
nix develop github:myobie/pty # dev shell with node, npm, native depsRequires Node.js. Works on macOS and Linux.
Run pty with no arguments to launch the interactive session manager:
╭─ pty ────────────────────────────────────────────────────────────╮
│ │
│ Filter: (type to filter) │
│ │
│ ● webserver ~/projects/myapp node server.js │
│ ● worker ~/projects/myapp npm run worker │
│ ● devlog ~/projects/myapp tail -f log/dev.log │
│ ○ migrations (exited 2h ago) npm run migrate │
│ + Create new session... │
│ │
╰──────────────────────────────────────────────────────────────────╯
↑↓ select ⏎ attach q quit
Arrow keys to navigate, type to filter, Enter to attach, q to quit. When pty-relay is installed, remote sessions appear grouped by host. Use host/session syntax to filter by host (e.g., prod/api). Creating a new session walks through a directory picker and name/command prompt.
When you detach from a session entered via the interactive list (Ctrl+\), you return to the list. The session keeps running in the background.
pty # interactive session manager
pty --preselect-new # open the TUI with "Create new session..." selected
pty --filter-tag layout=work # TUI filtered by tag; new sessions inherit the tag
pty run -- node server.js # start a session (random name + auto displayName)
pty run --name myserver -- node server.js # start with an explicit name (still gets a displayName)
pty run --no-display-name -- bash # random name, no friendly label (good for throwaway shells)
pty run -d -- node server.js # start in the background
pty run -a -- node server.js # create or attach if already running
pty run -e -- npm test # ephemeral: auto-remove on exit
pty run --tag owner=forge -- node srv.js # tag a session with metadata
pty run --cwd /path -- node server.js # run in a specific directory
pty rename my-label # inside a session: add/change its displayName
pty rename <ref> my-label # outside: set displayName on <ref>
pty rename --show <ref> # show current displayName
pty rename --clear [ref] # remove displayName
pty list # show active sessions (tags shown by default)
pty list --tags # include internal bookkeeping tags (ptyfile*, strategy, etc.)
pty list --json # show as JSON
pty list --remote # include remote sessions via pty-relay
pty list --filter-tag role=web # show only sessions with matching tag (repeatable)
pty attach myserver # reconnect to a session
pty attach -r myserver # reconnect, auto-restart if exited
pty exec -- codex # replace this session's process (inside a session)
pty peek myserver # print current screen and exit
pty peek --plain myserver # print as plain text (no ANSI)
pty peek --full myserver # print full scrollback
pty peek --wait "Listening" myserver # wait until text appears on screen
pty peek --wait "Ready" -t 10 myserver # wait with timeout (seconds)
pty peek -f myserver # follow output read-only
pty send myserver "hello" # send text (no implicit newline)
pty send myserver $'hello\n' # send text with newline (shell syntax)
pty send myserver --seq "git status" --seq key:return # ordered sequence
pty send myserver --seq key:ctrl+c # send control keys
pty send myserver --paste "$(cat prompt.md)" # wrap as bracketed paste
pty stats # live metrics for all sessions
pty stats myserver # stats for a specific session
pty stats --json # stats as JSON (includes CPU, memory, PIDs)
pty events myserver # follow events in real-time
pty events --all # follow events from all sessions
pty events --recent myserver # show recent events and exit
pty events --json myserver # output raw JSONL
pty emit user.deploy.started # emit a user event (inside a session)
pty emit myserver user.build.finished --json '{"ok":true}' # with JSON payload
pty emit myserver user.note --text "checkpoint reached" # with a text payload
pty state set myserver port 3000 # set a JSON-parsed state key
pty state set myserver config < cfg.json # pipe a bigger value from stdin
pty state get myserver port # print a single key's JSON value
pty state get myserver # print the whole bag (pretty JSON)
pty state keys myserver # list keys (one per line)
pty state delete myserver port # remove a key
pty restart myserver # restart an exited session
pty kill myserver # terminate a running session
pty rm myserver # remove an exited session's metadata
pty gc # remove all exited sessions
pty tag myserver role=web env=prod # set one or more tags on a session
pty tag myserver --rm role --rm env # remove one or more tags
pty tag-multi --filter-tag role=web env=prod # bulk write across matching sessions
pty tag-multi --all --json # bulk read tags across every session
pty tag-multi --all --yes audit=today # write to every session (--yes required)
pty supervisor start # start the session supervisor
pty supervisor stop # stop the supervisor
pty supervisor status # show supervised sessions
pty supervisor forget myserver # stop supervising a session
pty supervisor reset myserver # reset a failed session for retry
pty supervisor launchd install # install launchd auto-start (macOS)
pty supervisor systemd install # install user-level systemd auto-start (Linux)
pty supervisor runit install # install runit service files
pty up # start all sessions from ./pty.toml
pty up ./backend # start sessions from ./backend/pty.toml
pty up claude dev # start specific sessions from ./pty.toml
pty down # stop all sessions from ./pty.toml
pty down claude # stop specific sessions
pty wrap claude # auto-wrap claude in pty sessions
pty unwrap claude # remove the wrapper
pty wrap --list # show wrapped commandspty wrap creates a small shell script that shadows a command so it always runs in a pty session:
pty wrap claude
# Now running "claude" anywhere automatically gets a persistent sessionThe wrapper uses pty run -a (create or attach if already running), so running the command twice in the same directory reattaches instead of creating a duplicate.
Wrappers live in ~/.local/pty/bin/. Add it to the front of your PATH:
export PATH="$HOME/.local/pty/bin:$PATH"Detach with Ctrl+\. (Press Ctrl+\ twice to send it through to the process.)
If you run pty run (or a wrapped command) inside an existing pty session, pty detects the nesting via the PTY_SESSION environment variable and runs the command directly instead of creating a session-inside-a-session. This means wrapped commands "just work" inside pty sessions without double-wrapping.
Use pty run -d to explicitly create a background session from inside another session.
Sessions automatically log terminal events — bell, title changes, desktop notifications (OSC 9/99/777), focus requests, and cursor visibility transitions — plus metadata mutations: display_name_change on rename, tags_change on tag updates, state.set / state.delete on state bag writes, and any user.* events published via pty emit. Everything goes into per-session JSONL files.
pty events myserver # follow events live (like tail -f)
pty events --all # follow all sessions, interleaved
pty events --recent myserver # dump recent events and exit
pty events --json myserver # raw JSONL outputEvent files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL.
Session metadata, events, and supporting files all live under $PTY_SESSION_DIR (default ~/.local/state/pty). The full layout — file naming, JSON shape, atomic-write contract, event types, stability tiers — is documented in docs/disk-layout.md. Third parties can read these files directly to skip the Node CLI's startup cost; git-style command forwarding (pty <subcommand> resolves to a pty-<subcommand> binary on $PATH) lets you ship native fast-path readers as pty subcommands.
A project can include a pty.toml to declare its sessions:
[sessions.claude]
command = "claude --dangerously-skip-permissions"
tags = { role = "agent" }
[sessions.dev]
command = "deno task dev"
tags = { role = "build" }
[sessions.serve]
command = "bin/serve"
tags = { role = "server" }Run pty up in the project directory (or pty up /path/to/project) to start all sessions. Run pty down to stop them. You can also start specific sessions: pty up dev serve.
The supervisor keeps sessions alive by watching for the strategy tag:
# Tag a session as permanent
pty tag myserver strategy=permanent
# Start the supervisor
pty supervisor start
# If myserver exits, the supervisor restarts it with exponential backoff
# Max 5 restarts in 60 seconds before marking as [failed]
pty supervisor status # show supervised sessions
pty supervisor forget myserver # stop supervising
pty supervisor stop # stop the supervisorSessions can be supervised from pty.toml by setting the strategy tag:
[sessions.serve]
command = "bin/serve"
tags = { strategy = "permanent" }For auto-start:
- macOS:
pty supervisor launchd install— compiles a small wrapper binary and prompts for Full Disk Access (required for sessions on external/removable volumes) - Linux/systemd:
pty supervisor systemd install— installs a user service in~/.config/systemd/user/and enables it immediately. If you want it to start at boot before login, enable linger withsudo loginctl enable-linger $USER. - runit:
pty supervisor runit install— writes arunscript and symlinkable service directory. By default it uses~/.config/runit/{sv,service}; on systems like Void you can point it at/etc/svand/var/service.
Like git, pty supports extensions: if you run pty foo and there's a pty-foo executable in your $PATH, pty will run it with the remaining arguments. This lets you build your own subcommands without modifying pty.
@myobie/pty exposes a programmatic TypeScript API for building apps on top of pty sessions. Import from @myobie/pty/client.
import {
spawnDaemon, listSessions, getSession,
SessionConnection, sendData, peekScreen, queryStats,
EventFollower, readRecentEvents,
extractFilterTags, matchesAllTags,
} from "@myobie/pty/client";
import { PtyServer } from "@myobie/pty/server"; // native addon (node-pty)
import { resolveKey } from "@myobie/pty/keys"; // browser-safe
import { PacketReader, MessageType } from "@myobie/pty/protocol"; // browser-safe// Create a session
await spawnDaemon({
name: "myserver",
command: "node",
args: ["server.js"],
displayCommand: "node server.js",
cwd: "/path/to/project",
rows: 24,
cols: 80,
});
// List and query
const sessions = await listSessions();
const stats = await queryStats("myserver");SessionConnection provides a bidirectional, event-driven connection without taking over stdin/stdout — ideal for GUI apps, multiplexers, or web interfaces:
const conn = new SessionConnection({ name: "myserver", rows: 24, cols: 80 });
const initialScreen = await conn.connect();
conn.on("data", (data) => myTerminalView.write(data));
conn.on("exit", (code) => console.log(`Exited: ${code}`));
conn.write("hello\r");
conn.press("ctrl+c");
conn.resize(30, 100);
conn.disconnect();For simpler operations:
await sendData({ name: "myserver", data: ["hello\r"] });
const screen = await peekScreen({ name: "myserver", plain: true });const follower = new EventFollower({
names: ["myserver"],
onEvent: (event) => console.log(event.type, event.ts),
});
follower.start();See docs/client.md for the full API reference.
@myobie/pty includes a terminal testing library — like Playwright, but for the terminal. Spawn any process in a real PTY, send keystrokes, take screenshots, assert on visible output.
import { Session } from "@myobie/pty/testing";
const session = Session.spawn("node", ["--experimental-strip-types", "my-app.ts"]);
await session.waitForText("Ready");
session.press("down");
session.press("return");
await session.waitForText("Selected!");
const ss = session.screenshot();
expect(ss.text).toContain("Selected!");
expect(ss.lines[0]).toMatch(/My App/);
session.press("ctrl+c");
await session.waitForAbsent("My App");
await session.close();Works with any process: CLI tools, interactive TUIs, shells, vim, even top. The test runs in a real PTY with a real xterm terminal emulator, so you test exactly what users see.
See docs/testing.md for the full API reference, key names, patterns, and tips.
@myobie/pty also includes an experimental declarative TUI framework for building terminal interfaces with reactive signals, layout, and efficient cell-buffer diffing. Import from @myobie/pty/tui.
Alpha — the TUI framework API is unstable and will change. Use it for experiments, not production.
The demos/ directory has four working apps built with the framework:
- file-browser — two-pane directory tree + file preview with soft-wrap and markdown highlighting
- reminders — full CRUD backed by
.mdfiles, three views (list, board, calendar), overlays - agent-teams — live dashboard of a simulated AI agent hierarchy with real-time updates
- playground — interactive catalog of every TUI widget — atoms, layout, inputs, lists, data, overlays, and composition patterns, each with a live example and source snippet. A reference for anyone building on the TUI framework.
Run them with node --experimental-strip-types demos/{name}/main.ts (or ./demos/run <name>). Each demo includes unit tests and PTY integration tests that exercise the testing library.
For AI coding agents and automation, see docs/SKILL.md — a concise guide to running and managing background processes with pty, including session lifecycle, common patterns, and rules for well-behaved agents.
brew install bash-completion # required for bash on macOS; zsh works out of the box
npm run install-completionspty focuses on session persistence only — no splits, no panes, no window management. On mobile we don't need or want splits, and on desktop we have kitty/ghostty/native terminal splits. Keep things simple.
- abduco — minimal session management for terminal programs, handling detach and reattach cleanly. A major inspiration for pty.
- dtach — emulates the detach feature of screen with minimal overhead.
- GNU Screen — the original terminal multiplexer that pioneered session persistence.
- tmux — modern terminal multiplexer with session, window, and pane management.
MIT