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:compoundingtech/pty # install the CLI
nix develop github:compoundingtech/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 # random id + auto display label
pty run --id myserver -- node server.js # pin an explicit on-disk id
pty run --name "My API Server" -- node server.js # set an explicit display label (any length)
pty run --id api --name "My API" -- node server.js # pin both id and display label
pty run --no-display-name -- bash # random id, no friendly label
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 restart myserver # restart an exited session
pty kill myserver # terminate a running session
pty rm myserver # remove an exited session's metadata
pty gc # reconcile sessions: kill orphan children, respawn permanents, sweep exited
pty gc --dry-run # preview what gc would do without changing anything
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist # install macOS auto-gc
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 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 sessionsIf you run pty run 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.
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, 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_ROOT (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.
pty is single-registry by default — every list, gc, kill, tag, and attach operates on the same directory tree. Two tools sharing a machine can compose two levels of isolation on top of that: filter by tag (soft: everyone still sees each other but scoped views are cheap) and switch registry (hard: the underlying state directory is different, sessions are invisible across).
Soft isolation via tags — any tool can stamp a namespace tag at spawn and filter on it:
pty run --tag app=payments -- ./bin/worker
pty list --filter-tag app=payments # only payments sessions
pty list --filter-tag app=payments --filter-tag role=worker # combine, ALL must matchThe primitive is --filter-tag k=v (repeatable, matches ALL). Any tool layering on top — smalltalk uses st.network=<root> to distinguish agents in its network from an operator's ad-hoc pty use — reads and writes tags through the same primitive; no pty semantics for the outer tool's key.
Hard isolation via --root — pin the state registry per call, or per environment:
pty --root /var/lib/pty-eval list # one-off scope for this invocation
PTY_ROOT=/var/lib/pty-eval pty list # scope for a whole shell / process treeDistinct roots share no sockets, no metadata, no events, no gc. A launchd cron (pty gc --print-launchd-plist) inherits the current $PTY_ROOT and bakes a per-root Label (com.myobie.pty.gc.<basename>) plus per-root log path, so N isolated registries can each install their own gc plist without collision. On the default root the Label stays com.myobie.pty.gc — existing installs survive an upgrade unchanged.
PTY_SESSION_DIR (the pre-Phase-2 name for the same env var) still works and emits a one-time deprecation notice. Set PTY_ROOT_LEGACY_SILENT=1 to suppress the notice while migrating.
PTY_ROOT is the isolation mechanism — use it, not PTY_SESSION_DIR. When both are set, PTY_ROOT (canonical) wins and the deprecated PTY_SESSION_DIR is ignored — with a one-time warning so the masking is visible. So a scratch/test harness running inside an environment that already exports PTY_ROOT (e.g. a supervised session tree) must set PTY_ROOT to isolate; setting only PTY_SESSION_DIR would be silently overridden by the ambient PTY_ROOT and its sessions would land in the ambient registry.
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.
Each session also supports two optional fields:
[sessions.serve]
command = "bin/serve"
id = "srv" # pin the on-disk id (sock + json filename)
display_name = "My Web Server" # override the default `<prefix>-<sessionKey>` labelid is validated like a pty run --id value (charset, sock-path length, uniqueness); omitted → pty generates a short random id at spawn time. display_name is permissive (≤ 500 chars, any printable text); omitted → defaults to <prefix>-<sessionKey> (or just <sessionKey> if no prefix). The two fields decouple the human label from the kernel-constrained filename — long prefixes that would have blown past sockaddr_un.sun_path (~104 bytes) now work because the actual sock filename is just the short id.
Sessions can also declare per-session environment variables:
[sessions.api]
command = "bin/api"
[sessions.api.env]
PORT = "8080"
LOG_LEVEL = "debug"The values are exported into the session's shell before the command runs — pty up wraps every toml-managed session in /bin/sh -c so the export K='V'; … prefix is honored. They take effect on the next pty up after the session has stopped — restarting a still-running session via pty restart reuses the existing spawn args, so pty kill <name> followed by pty up is the way to pick up a changed env block on an already-running session.
Tag a session with strategy=permanent and pty gc will respawn it whenever its daemon exits or vanishes:
pty tag myserver strategy=permanent
# After myserver exits — manually or by crash — the next `pty gc` run
# brings it back. No backoff, no retry budget; the cron interval below
# is the rate limit. Sessions managed by pty.toml re-read the toml on
# respawn so command/env edits take effect immediately.
pty gcFrom pty.toml:
[sessions.serve]
command = "bin/serve"
tags = { strategy = "permanent" }Restart is stateless — every pty gc invocation re-derives intent from on-disk metadata. There's no in-memory restart counter, no [failed] state, no persisted bookkeeping. If a session's binary isn't reachable (volume not mounted, broken symlink), pty gc reports Respawn failed: and the next tick tries again.
Fast-fail cap — a permanent session whose leaf exits within strategy.fast-fail-window seconds of its previous pty gc respawn counts as a fast fail. After strategy.fast-fail-limit consecutive fast fails, pty gc writes strategy.status=flapping on the session, emits a session_flapping event, and stops respawning it. Subsequent gc ticks print Skipped (flapping): <name> and take no action. Defaults: 60 s window, 3 consecutive fast fails.
A flagged session shows [flapping] (red) in pty list in place of [permanent] — the operator's expectation has changed, so the badge reflects that.
Reset a flagged session with one of:
pty restart <name>orpty up— the manual respawn drops all fast-fail bookkeeping (strategy.status,strategy.consecutive-fast-fails,strategy.last-respawn-at,strategy.command-hash), treating restart as an operator "please try again" signal.pty tag <name> --rm strategy.status— surgical reset that clears only the mark, leaving the counter intact for observability.- Edit the session's
pty.tomlcommand — the classifier notices the SHA-256 fingerprint change and auto-resets the counter and mark on the next gc tick.
Per-session overrides tune the cap without editing gc's globals:
pty tag myserver strategy.fast-fail-window=120 # allow 2min of runtime before "fast"
pty tag myserver strategy.fast-fail-limit=5 # tolerate 5 fast fails before flappingCLI globals mirror the per-session tags (--fast-fail-window=N, --fast-fail-limit=N); the per-session tag wins when both are set.
Tag a session with parent=<name> and pty gc will SIGTERM it (and clean up its metadata) when the referenced parent's daemon is no longer alive — useful for sidecar workers that shouldn't outlive their primary:
pty run -d --name webserver -- bin/serve
pty run -d --name webserver-tail --tag parent=webserver -- tail -f log/web.log
# If `webserver` dies, the next `pty gc` SIGTERMs `webserver-tail`.What triggers the kill: the parent's metadata file is gone OR the parent's pid file is gone OR the parent's process isn't alive. What doesn't: the parent's exit code, the parent's exitedAt timestamp. Combinator with strategy=permanent is well-defined — orphan-kill wins (the child is removed, not respawned).
Cycles (A→B, B→A) resolve deterministically by name-sorted iteration: whichever name sorts first dies first on the tick where both parents are gone; the loser dies the same tick because its parent (the just-killed winner) is also dead. No cycle detection needed.
pty gc is a one-shot reconciliation pass. The intended deployment is to run it on a short interval so permanent sessions come back quickly and orphans get cleaned promptly. The CLI ships an install helper for macOS:
pty gc --print-launchd-plist > ~/Library/LaunchAgents/com.myobie.pty.gc.plist
launchctl load ~/Library/LaunchAgents/com.myobie.pty.gc.plistDefault interval is 30 seconds; tune with pty gc --print-launchd-plist --interval=15 etc. Output goes to ~/.local/state/pty/gc.log.
Statelessness is the whole point of running it on a cron rather than as a long-lived daemon. At boot, if the volume containing the pty binary isn't mounted yet, the invocation fails — the next tick tries again. The historic long-running supervisor would burn through its 5-retry budget in the first 10 seconds of boot and never come back; this design just shrugs and reconciles on the next tick.
For other systems:
- Linux + cron:
* * * * * pty gc >> ~/.local/state/pty/gc.log 2>&1(one-minute resolution; tune to taste). - Linux + systemd-timer:
OnUnitActiveSec=30son apty.gc.servicethatExecStart=pty gc. - runit: a
runscript that loopspty gcwith asleep 30between iterations.
The macOS pty gc --print-launchd-plist helper is the only one bundled today; the others are one-liners and easy enough to write yourself. File an issue if you'd like a built-in install command for systemd/runit.
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