@mentiko/pty-mgr
v1.8.0
Published
PTY session manager with terminal emulation for programmatic session control.
Maintainers
Readme
pty-mgr
PTY session manager with terminal emulation for programmatic session control.
Spawn commands in real pseudo-terminals, capture rendered screen output (not raw bytes), and manage sessions through a persistent daemon. Single binary, no external dependencies.
How It Works
Bun.spawn({ terminal }) allocates a native PTY for each session. An
@xterm/headless terminal emulator parses escape codes, cursor movements, and
screen redraws so capture() returns exactly what you'd see on screen.
Compiles to a single self-contained binary via bun build --compile.
Install
Binary (no dependencies)
curl -fsSL https://raw.githubusercontent.com/kollaborai/pty-mgr/main/install.sh | shInstalls to ~/.pty-mgr/bin/ and adds to PATH. Works on Linux and macOS (x64, arm64).
NPM package
npm install -g @mentiko/pty-mgrInstalls the wrapper plus the platform binary for your OS/arch.
Bun package (for library use)
bun add @mentiko/pty-mgrRequires Bun runtime.
Quick Start
As a library
import { PtyManager } from '@mentiko/pty-mgr';
const mgr = new PtyManager();
mgr.spawn('my-session', 'zsh', [], { cols: 120, rows: 30 });
mgr.sendKeys('my-session', 'echo hello\r');
// rename a running session
mgr.rename('my-session', 'my-session-renamed');
// wait for output, then capture rendered screen
setTimeout(() => {
console.log(mgr.capture('my-session-renamed', 5));
mgr.kill('my-session-renamed');
}, 1000);As a CLI
# start the daemon (forks to background)
p daemon
# named daemon for isolated environments
p daemon @myproject
# spawn a session
# auto-incrementing session named after current directory
p wrap # spawns as pty-mgr-1
p wrap # spawns as pty-mgr-2
p wrap # spawns as pty-mgr-3
p spawn agent-1 claude --print
# send keystrokes
p send agent-1 "fix the login bug"
# capture rendered screen (last 20 lines)
p capture agent-1 20
# send raw text with no trailing enter
p send agent-1 --raw "partial input"
# poll whether a session is still working (diffs two captures)
p watch agent-1 4s
# attach interactively (ctrl-] to detach)
p attach agent-1
# watch two sessions side-by-side, live (q or Ctrl-C to exit)
p view agent-1 agent-2
# rename a session
p rename agent-1 agent-refactored
# bulk operations with globs
p capture all 50
p kill refa*
# daemon status + config
p status
# stop daemon
p stopCommand Reference
Every command (p --help):
| Command | Description |
|---------|-------------|
| p daemon | Start daemon (forks to background) |
| p daemon @myproject | Named daemon (isolated sessions) |
| p daemon @@chatroom | Chat room: every session hears every other |
| p status | Daemon info + config |
| p config | Show current config |
| p config screen 100x50 | Set default terminal size |
| p config cap-on-send on\|off | Return a capture with every send |
| p config send-delay <ms> | Delay before enter (default 1000) |
| p spawn <name> [cmd] [args...] | Create session (default zsh) |
| p wrap [cmd] [args...] | Spawn with auto-incrementing cwd name |
| p attach <name> | Interactive mode (ctrl-] detach) |
| p view <name1> <name2> [interval] | Read-only split-pane live viewer |
| p send <name> <text> | Send text + enter |
| p send <name> --raw <text> | Send text as-is (no enter) |
| p capture <name> [lines] | Capture rendered screen |
| p capture all [lines] | Capture from all sessions |
| p capture <glob*> [lines] | Capture matching sessions |
| p watch <name> [interval] | Diff two bottom-100 captures → done/working |
| p list | List all sessions |
| p alive <name> | Check if a session is alive |
| p info <name> | Session details |
| p kill <name> | Kill session |
| p kill all | Kill all sessions |
| p kill <glob*> | Kill matching sessions |
| p link <a> <b> | Relay each agent's finished turn into the other |
| p link <a> <b> --note <text> | Append steering text to every relayed turn |
| p link <a> <b> --max <n> | Stop the ping-pong after n hops per side |
| p link <a> <b> --one-way | a → b only |
| p link all | Chat room mode on (same as @@ at boot) |
| p link / p links | List links + room + last relay status |
| p unlink <a> [b] | Drop links touching a (or just a ↔ b) |
| p unlink all | Kill switch: drop every link, leave room mode |
| p rename <old> <new> | Rename a session |
| p remove <name\|all\|glob*> | Kill + remove |
| p log <name> on [jsonl\|raw\|rendered] | Start logging |
| p log <name> off | Stop logging |
| p spawn <name> --log [cmd] | Spawn with logging (jsonl) |
| p stop | Stop current daemon |
| p stop all | Stop all daemons |
| p setup | Wrap CLI tools (claude, etc.) |
| p flow list [--verbose] [--config file] | List configured agent workflows |
| p flow show <name> [--config file] | Show one configured agent workflow in detail |
| p flow run <name> --task <text> | Run a configured agent workflow |
| p tg <message> | Send a Telegram notification |
| p tg <message> --reply [--timeout N] | Send and block for a reply |
| p demo | Run self-test (no daemon needed) |
p watch <name> [interval] captures the bottom 100 lines, waits interval
(default 4s; accepts 4s, 1000ms, or a bare millisecond value), captures
again, and prints done if the screen is unchanged or working if it moved —
a cheap idle check for polling long-running agents.
Side-by-Side Viewer
p view <name1> <name2> renders two sessions side-by-side in split panes with a
vertical divider and live refresh. It's read-only — great for watching two agents
work in parallel (e.g. a writer and a reviewer in a code-review flow) without
switching terminals.
p daemon
# spawn two agents
p spawn writer claude
p spawn reviewer claude
# watch both live, side-by-side (refresh every 500ms by default)
p view writer reviewer
# custom refresh interval (4s, 1000ms, or bare milliseconds)
p view writer reviewer 1000ms
# "v" is an alias for "view"
p v writer reviewerThe viewer enters the alternate screen, draws a header row with each session's
name, refreshes both panes on the interval you set, and handles terminal resizes.
Press q or Ctrl-C to exit. Requires at least 21 terminal columns.
Linked Agents
p link wires two running agents mouth-to-ear. Each one's finished turn is
typed into the other, so they keep going without an orchestrator process
babysitting them.
p spawn advisor
p send advisor "claude --dangerously-skip-permissions --model sonnet"
p spawn bug-finder
p send bug-finder "claude --dangerously-skip-permissions --model sonnet"
p link advisor bug-finder
p send bug-finder "Walk the code and tell me the first bug you find. Ask me how to fix it."bug-finder reports the bug → it lands in advisor → advisor answers → the answer
lands back in bug-finder → repeat, indefinitely, until you p unlink.
How it works: the agent CLI's own end-of-turn hook is the trigger. p link
installs claude's Stop hook (idempotent; codex uses its notify program —
p relay hook install prints the line). When a turn ends, the hook hands the
turn-final text to the daemon, which types it into the linked session with the
ordinary send path. Same hook the message relay
uses, so linking and chat bridging coexist.
p link # list links + last relay status
advisor -> bug-finder relayed=3 ok
bug-finder -> advisor relayed=3 ok
p link a b --note "Reply in under 10 lines." # steering appended to every hop
p link a b --max 20 # stop after 20 hops per side
p link a b --one-way # a -> b only
p unlink advisor # kill switchLinks live in the daemon and are dropped when a session is removed — a link
only means anything while both sides are alive. Repeat turns, empty turns, and
dead targets are dropped rather than injected; p links shows the last status
per direction.
Chat rooms
A chat room is the same thing with N members instead of a named pair. Start the
daemon with a doubled @ and every session spawned into it hears every other
one, prefixed with the speaker's name:
p daemon @@chatroom
p @@chatroom spawn advisor
p @@chatroom send advisor "claude --dangerously-skip-permissions --model sonnet"
p @@chatroom spawn bug-finder
p @@chatroom send bug-finder "claude --dangerously-skip-permissions --model sonnet"
p @@chatroom spawn coder
p @@chatroom send coder "claude --dangerously-skip-permissions --model sonnet"
p @@chatroom send bug-finder "Walk the code and tell me the first bug you find."Every turn goes out to everyone else as bug-finder: … / advisor: … /
coder: …, so three agents in one room stay tellable apart. There is nothing
to wire: membership is being spawned there, and a session spawned into a room
that is already talking joins it immediately. The speaker never hears its own
turn back.
@@name and @name select the same daemon — the second @ only means
anything to p daemon, which is what boots the room. Everywhere else it is a
harmless way to keep typing the room's name the way you created it.
p link # room status, members, broadcast count, last hop
chat room: on members=advisor, bug-finder, coder
broadcasts=3 last=bug-finder ok (2)
p unlink all # kill switch: leave room mode, agents keep running
p link all # back on (also promotes a plain daemon to a room)
p status # shows "chat room: on (N broadcasts)"p unlink all is worth knowing before you start one: N agents in a room each
answer every message, so turns multiply in a way the two-agent ping-pong does
not. It stops the broadcasting without killing the agents, so nothing in
flight is lost.
Who said it. Messages you send into a room arrive labelled [human]:,
because an agent's turn already carries advisor: and an unlabelled human
message left "the human told me to push" impossible to check. The label is a
role, not a name, and it is not a legal session name — so nothing spawned in
the room can impersonate it, and the reserved token is rewritten to (human)
inside relayed agent messages so an agent can quote you without inheriting your
authority. p send --raw sends with no label.
Ending a room. Rooms damp repetition — a turn that normalizes to something
the same speaker said in its last few turns is dropped, so (holding) followed
by (Holding.) does not propagate. That is a damper, not a stop: agents that
keep finding new wording for "nothing to add" will keep talking. Use --max
as the fuse and p unlink all as the brake.
Pairwise p link a b is refused inside a room — everyone already hears
everyone.
p link vs p flow: a flow is a scripted workflow with a fixed turn order and
a foreground process driving it. A link (or a room) is just a wire — no cycle
count, no script, no supervising process, and any member can be talked to by
hand at any time.
Agent Flows
p flow runs configurable agent-collaboration workflows. The workflow lives in
pty-mgr.config.json; the binary provides the PTY/session/log plumbing.
p daemon
p flow list # list configured flow names
p flow list --verbose # show each flow's agents + routing
p flow show code-review # full detail: agents, turns, settings
p flow run spec-writer --task "Create a spec for the auth rewrite."
p flow run review-loop --task "Review this repo." --max-cycles 2
# one agent implements, another reviews the real git diff, the first revises
p flow run code-review --task "Add rate limiting to the /login route" --cwd ~/dev/myappBoth agents in a flow launch in the same working directory (--cwd, or wherever
you run the command), so a reviewer agent can open the actual files the writer
produced — not just react to its chat summary.
A flow has:
adapters: how to launch each CLI and parse sent user + assistant logs.agents: named participants in a workflow, each using an adapter kind.start: which agent receives the initial task.turns: routing and steering templates between agents.
Useful run flags:
--goal <text>: separate long-running goal text from the initial task.--max-cycles <n>: override the workflow's configured cycle count.--watch-interval <duration>: compare bottom-100-line captures after this interval to detect a stable screen (4s,1000ms, or a bare millisecond value; default10s).--interval-ms <n>: poll cadence between stability checks (default1000).--settle-ms <n>: wait after a stable capture before reading the transcript.--timeout-ms <n>: return incomplete instead of waiting forever.
Example steering:
{
"from": "author",
"to": "reviewer",
"append": "Based on this, what gaps do you see and what should change next?"
}Supported template variables:
{task}: the initial task passed on the CLI.{goal}:--goalif provided, otherwise the task.{lastMessage}: the completed assistant response being relayed.{cycle}: current cycle number.{from}/{to}: source and target agent names.
The shipped pty-mgr.config.json includes three examples for Claude/Codex:
spec-writer: author drafts, reviewer critiques, author revises.review-loop: two agents trade a task back and forth.code-review: Codex implements the task, then Claude reviews the actualgit diff(correctness, edge cases, security) and Codex applies the fixes. This is the "one agent writes code, another reviews it" workflow — run it withp flow run code-review --task "..." --cwd <repo>.
Add new adapters for Gemini, OpenCode, or another CLI by defining where that tool stores JSONL logs and how to extract the latest sent user message and completed assistant message.
Flow transcript lookup is bound to the exact user prompt sent to the session, then assistant extraction starts after that prompt. That prevents one active agent from accidentally relaying another agent's newer log.
CLI Aliases
| Short | Full | Short | Full | |-------|---------|-------|---------| | n/new | spawn | w/wrap | wrap | | s | send | k | kill | | c/cap | capture | l/ls | list | | a | attach | v | view | | st | status | mv/ren | rename | | i | info | d | daemon | | cfg | config | x | stop |
Managed CLI Sessions
Wrap any CLI tool (claude, codex, gemini, etc.) in managed PTY sessions. Run the interactive setup:
pty-mgr setupIt asks which commands to wrap, then adds shell functions to your rc file.
After that, just type claude like normal. What you get:
- Claude runs inside a managed PTY session named
<folder>-1 - If you open another claude in the same folder, it gets
<folder>-2 ctrl-]to detach -- Claude keeps running in the backgroundp attach <name>to jump back inp capture <name> 50to check on it from another terminalp listto see all your Claude sessions across all projects
$ cd ~/dev/my-app
$ claude # spawns as my-app-1, attaches
ctrl-] # detach
$ claude # spawns as my-app-2
ctrl-]
$ p list
my-app-1 pid=1234 120x40 alive claude
my-app-2 pid=1235 120x40 alive claude
$ p capture my-app-1 20 # peek at what agent 1 is doing
$ p attach my-app-1 # jump back into agent 1Programmatic (parallel agents)
import { PtyManager } from '@mentiko/pty-mgr';
const mgr = new PtyManager();
// launch 3 Claude agents in parallel
const agents = ['auth-fix', 'api-tests', 'docs-update'];
for (const name of agents) {
mgr.spawn(name, 'claude', ['--print'], { cols: 120, rows: 40 });
}
mgr.sendKeys('auth-fix', 'fix the login bug in src/auth.ts\r');
mgr.sendKeys('api-tests', 'write tests for the /users endpoint\r');
mgr.sendKeys('docs-update', 'update the API docs in README.md\r');
// poll until all agents finish
for (const name of agents) {
await mgr.waitFor(name, /[✔✓]|completed|done/i, 120000);
console.log(`${name} done:\n${mgr.capture(name, 10)}\n`);
}
mgr.destroyAll();Telegram Remote Control
Drive your sessions from Telegram — check on agents, capture output, send input, and get notified when a task needs you, all from your phone.
Set two env vars before starting the daemon:
export TELEGRAM_BOT_TOKEN=<token from @BotFather>
export TELEGRAM_CHAT_ID=<your chat id>
p daemonWhen TELEGRAM_BOT_TOKEN is set, the daemon starts a bot poller automatically.
Only messages from TELEGRAM_CHAT_ID are honored. Bot commands:
| Command | Description |
|---------|-------------|
| /list (/ls) | List sessions |
| /capture <name> [lines] (/cap, /c) | Capture output (defaults to last used session) |
| /send <name> <text> (/s) | Send text + enter, then auto-capture once the session settles |
| /kill <name> (/k) | Kill a session |
| /spawn <name> [cmd] (/n) | Spawn a session |
| /status | Daemon info |
| /help (/start) | List commands |
/capture and /send remember the last session you touched, so you can omit
the name on follow-ups.
From inside a session (or a script), push notifications the other direction with
the tg command:
p tg "build finished, needs review" # fire-and-forget notification
p tg "approve deploy? (y/n)" --reply # block until you reply, print it
p tg "still there?" --reply --timeout 120 # wait up to 120s (default 60)--reply blocks the caller until you answer in Telegram and prints your reply to
stdout — useful for human-in-the-loop gates inside an agent flow. The tg sender
uses PTY_MGR_SESSION (set automatically in wrapped/managed sessions) to tag
which session is asking.
Daemon Protocol
The daemon listens on a Unix socket at ~/.pty-manager/<name>.sock.
Communication is newline-delimited JSON:
{"cmd": "spawn", "name": "agent-1", "args": {"cmd": "zsh"}}
{"cmd": "wrap", "args": {"cmd": "zsh", "cwd": "/Users/you/dev/myproject"}}
{"cmd": "send", "name": "agent-1", "args": {"text": "echo hi\r"}}
{"cmd": "capture", "name": "agent-1", "args": {"lines": 20}}
{"cmd": "resize", "name": "agent-1", "args": {"cols": 120, "rows": 40}}
{"cmd": "list"}
{"cmd": "info", "name": "agent-1"}
{"cmd": "alive", "name": "agent-1"}
{"cmd": "status"}
{"cmd": "config", "args": {"key": "screen", "value": "120x40"}}
{"cmd": "log", "name": "agent-1", "args": {"action": "on", "format": "jsonl"}}
{"cmd": "kill", "name": "agent-1"}
{"cmd": "remove", "name": "agent-1"}
{"cmd": "rename", "name": "agent-1", "args": {"newName": "agent-refactored"}}
{"cmd": "tg-send", "args": {"message": "done"}}
{"cmd": "shutdown"}The attach command switches the connection to raw streaming mode for
interactive use.
Configuration
p status # daemon info + current config
p config # show current config
p config screen 120x40 # default terminal size for new sessions
p config cap-on-send on # return capture with every send command
p config send-delay 500 # ms to wait before the trailing enter (default 1000)Config persists in the daemon and applies to new sessions.
Logging
p spawn agent-1 --log claude # spawn with auto-logging (jsonl)
p log agent-1 on jsonl # start logging an existing session
p log agent-1 off # stop loggingFormats: jsonl (timestamped events), raw (PTY bytes), rendered (screen snapshots).
Build
bun run build # compiles to dist/pty-mgr (single binary, ~60MB)License
MIT
