pty-prompt
A PTY broker for driving interactive, prompt-based programs from an agent (LLM) or any caller that cannot hold a persistent connection.
Tools like git add -p, git rebase -i, REPLs, or installer wizards expect a
real terminal on the other end and talk in a back-and-forth of prompt → answer
→ new prompt. An LLM tool-call, however, is a one-shot process invocation: it
starts, does a thing, and exits. There is nothing to keep the interactive
program — or its pseudo-terminal — alive between calls.
pty-prompt solves this by splitting into two parts:
- a background daemon that owns the PTY, the spawned child process and a rolling buffer of everything the child has written so far, and
- a thin CLI client you invoke once per request, which talks to the daemon over a unix-domain socket.
So the agent just shells out to pty-prompt step ... repeatedly; the daemon
holds the session open and remembers the latest output in between.
The whole interface: five verbs
# Start a session. Everything after `--` is the command to run.
# Prints the program's initial output once it settles.
pty-prompt start -- git add -p
# Iterate: write TEXT + Enter, wait for the output to settle, and print
# everything produced since the input — i.e. the next prompt.
pty-prompt step y
# With no TEXT, step just re-polls (e.g. while a program is still working).
pty-prompt step
# Block until the child exits, then print everything since the call + exit
# status. For non-interactive commands where you just want the result.
pty-prompt wait
# Graceful shutdown (SIGTERM, then SIGKILL after a grace period).
pty-prompt stop
# Force shutdown (SIGKILL now) — for a wedged child that ignores SIGTERM.
pty-prompt kill-session
step is the one verb you iterate with. It always appends Enter, so you never
have to think about line submission. The reply contains everything the child
produced since your input (including the echo of what you typed), so you see the
freshly-rendered prompt in the same call — no separate "read" step.
How "done" is detected
A prompt-based program never tells you "I'm finished, your turn". So after
sending input, step waits until the child has produced no new bytes
for --timeout milliseconds (default 500), then returns whatever
accumulated. This is a heuristic — bump --timeout for slow programs.
When you don't want a heuristic at all — e.g. a non-interactive build where the
only thing you care about is the final result — use wait instead. It
blocks until the child actually exits, then prints everything produced since
the call plus the exit status. One call, no polling, no guessing a quiet window.
--max-wait (default 60m) caps the block so a wedged child can't hang you:
pty-prompt start --session build -- nix build .#thing -L
pty-prompt wait --session build # blocks until the build finishes
When the child exits, step reports it in the status line (see below). The
daemon stays up after the child exits so a final step can still retrieve the
last output; stop/kill-session then tear it down.
The status line
Every invocation prints a single status line to stderr (program output stays on stdout, so the two never mix). It is always printed — including on an empty poll while a program is still working — because that is exactly when the timing is most useful:
[pty-prompt: 13:58:42 | running for 16s, quiet for 1.5s, still running]
[pty-prompt: 14:00:56 | running for 2m14s, quiet for 0.3s, exited: exit status 1]
[pty-prompt: 14:00:56 | ran for 2m14s, exited, session "build" stopped]
- HH:MM:SS — the wall-clock time the line was printed. Gives an absolute reference when polling across separate invocations, where relative durations alone don't tell you how much real time has elapsed between calls.
- running for — time since the session (daemon) started.
- quiet for — time since the child last produced output. A large and growing "quiet for" while "running for" keeps climbing is how you tell a slow-but-busy program from a hung one.
- The final clause is the child's state:
still running,exited, orexited: <error>.
Errors use the same bracketed form, e.g.
[pty-prompt: session "build" already running for 16s (stop it first)].
Output is clean by default
Output is ANSI-stripped by default (escape sequences removed, CR dropped),
so you get plain, line-oriented text ready to read. Pass --raw to keep escape
codes verbatim — useful for full-screen TUIs where cursor movement matters.
Sessions
Multiple independent programs can run at once via --session NAME
(default: default):
pty-prompt start --session rebase -- git rebase -i HEAD~5
pty-prompt step --session rebase x # mark a commit for edit
pty-prompt stop --session rebase
Options
| Flag | Applies to | Meaning |
|---|---|---|
--session NAME |
all | Session name (default default). |
--timeout MS |
start / step | Quiet window that counts as "settled" (default 500 ms). |
--max-wait MS |
wait | Cap on how long to block for exit (default 60 min). |
--raw |
start / step / wait | Keep ANSI escape codes instead of stripping them. |
Worked example: git add -p
pty-prompt start -- git add -p # shows the first hunk + "Stage this hunk [y,n,...]?"
pty-prompt step y # stage it; shows the next hunk
pty-prompt step n # skip this one
pty-prompt step s # split a hunk
pty-prompt step q # quit
# git exits on its own; the last step's status line ends with "exited".
pty-prompt stop # clean up
Notes & caveats
- State lives in
$XDG_RUNTIME_DIR/pty-prompt/<session>.sock(falls back to$TMPDIR). Daemon stderr goes to<session>.sock.lognext to it.stop/kill-sessionremove both. - The daemon shuts down on
stop/kill-session, or onSIGTERM/SIGINT, via acontext.Contextthat cancels the read loop, accept loop and child cleanly. - Output buffer is capped at the last 1 MiB to bound memory on chatty programs.
Build / install
nix build .#pty-prompt
nix profile install .#pty-prompt
# or for local dev:
go run ./users/Profpatsch/pty-prompt -- bash
Full reference documentation is in the manpage:
man pty-prompt
# or, without installing:
man ./users/Profpatsch/pty-prompt/pty-prompt.1