This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

151 6 19

Clone this repository

https://tangled.org/compounding.tech/pty https://tangled.org/did:plc:hteb4cp225jyywn6aaznojll
git@tangled.org:compounding.tech/pty git@tangled.org:did:plc:hteb4cp225jyywn6aaznojll

For self-hosted knots, clone URLs may differ based on your setup.



README.md

pty#

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.

Install#

npm install -g @myobie/pty

Or with Nix:

nix profile install github:myobie/pty   # install the CLI
nix develop github:myobie/pty           # dev shell with node, npm, native deps

Requires Node.js. Works on macOS and Linux.

Usage#

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.

Commands#

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 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 commands

Wrapping Commands#

pty 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 session

The 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.)

Nesting Prevention#

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.

Events#

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 output

Event files auto-truncate at 1,000 lines and are cleaned up with the 24-hour dead session TTL.

On-disk format#

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.

Project Files#

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>` label

id 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 (the supervisor wraps every session command in /bin/sh -c). 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.

Supervisor#

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 supervisor

Sessions 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 with sudo loginctl enable-linger $USER.
  • runit: pty supervisor runit install — writes a run script and symlinkable service directory. By default it uses ~/.config/runit/{sv,service}; on systems like Void you can point it at /etc/sv and /var/service.

Plugins#

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.

Client API#

@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

Managing sessions#

// 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");

Connecting to a session#

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 });

Following events#

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.

Testing Library#

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

TUI Framework (alpha)#

@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 .md files, 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.

Skill Reference#

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.

Tab Completion#

brew install bash-completion  # required for bash on macOS; zsh works out of the box
npm run install-completions

Prior Art#

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

License#

MIT