Profpatsch/users/Profpatsch/pty-prompt
- .gitignore 30 B
- README.md 6.6 KiB
- default.nix 493 B
- go-deps.nix 162 B
- go.mod 118 B
- go.sum 163 B
- main.go 27.8 KiB
- pty-prompt.1 8.5 KiB
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
pty-prompt(1)
NAME
pty-prompt - PTY broker for driving interactive prompt-based programs
SYNOPSIS
pty-prompt
start
[--session name]
[--timeout ms]
[--raw]
--
cmd [args ...]
pty-prompt
step
[--session name]
[--timeout ms]
[--raw]
[text]
pty-prompt
stop
[--session name]
pty-prompt
kill-session
[--session name]
DESCRIPTION
pty-prompt drives interactive, prompt-based programs — such as git add -p, git rebase -i, REPLs, or installer wizards — from a caller that cannot hold a persistent connection, most notably a large language model (LLM) agent.
Such programs expect a real terminal and converse 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, leaving nothing to keep the interactive program — or its pseudo-terminal — alive between calls.
pty-prompt solves this by splitting into two cooperating 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 — the command you invoke once per request — which talks to the daemon over a Unix-domain socket.
The agent therefore simply shells out to pty-prompt step repeatedly; the daemon holds the session open and remembers the latest output in between.
Detecting completion
A prompt-based program never announces that it has finished and is awaiting input. After sending input, pty-prompt waits until the child has produced no new bytes for --timeout milliseconds (default 500), then returns whatever accumulated. This is a heuristic; raise --timeout for slow programs.
When the child exits, the status line (see Status line) reports it. The daemon stays up after the child exits so a final step can still retrieve the last output; stop or kill-session then tears it down.
Output
Output is ANSI-stripped by default: escape sequences are removed and carriage returns dropped, yielding plain, line-oriented text. Pass --raw to keep escape codes verbatim, e.g. for full-screen TUIs where cursor movement matters.
Status line
Every invocation prints a single status line to standard error (program output stays on standard output, so the two never mix). It is always printed, including on an empty poll while a program is still working, since that is 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]
The leading
HH:MM:SS
is the wall-clock time the line was printed — an absolute reference across
polls, since relative durations alone do not convey how much real time elapsed
between separate invocations.
running for
is the time since the session started;
quiet for
is the time since the child last produced output (a large, growing
quiet for
distinguishes a slow-but-busy program from a hung one). The final clause is the
child's state:
still running,
exited,
or
exited: <error>.
Errors use the same bracketed form, e.g.
"[pty-prompt: session "build" already running for 16s (stop it first)]".
COMMANDS
start [options] -- cmd [args ...]
Spawn cmd under a PTY held by a freshly forked, detached daemon, then print the program's initial output once it settles. Everything after -- is the command to run. Fails if a session of the same name is already running.
step [options] [text]
The verb you iterate with. Write text followed by Enter, wait for output to settle, and print everything produced since the input — that is, the freshly rendered next prompt (including the echo of what you typed). With no text, step only re-polls, which is useful while a program is still working.
wait [--session name] [--max-wait ms] [--raw]
Block until the child exits, then print everything produced since the call plus the exit status. Unlike step, there is no quiescence heuristic: the only stopping condition is real completion. Use this for non-interactive commands (e.g. a build) where you just want the result. --max-wait caps the block (default 60 minutes) so a wedged child cannot hang the caller.
stop [--session name]
Gracefully terminate the child (SIGTERM, then SIGKILL after a grace period) and shut the daemon down, removing its socket and log file.
kill-session [--session name]
Force-terminate the child (SIGKILL immediately) and shut the daemon down. Use when a child is wedged and ignores SIGTERM.
OPTIONS
--session name
Operate on the named session. Multiple independent programs may run at once. Defaults to default, so simple use needs no name.
--timeout ms
The quiescence window, in milliseconds: how long the child must stay quiet before its output is considered settled. Applies to start and step. Default 500.
--max-wait ms
The hard cap, in milliseconds, on how long wait will block for the child to exit. Default 3600000 (60 minutes).
--raw
Return output with ANSI escape codes intact instead of stripping them. Applies to start, step and wait.
EXAMPLES
Drive git add -p
$ pty-prompt start -- git add -p # shows first hunk + prompt
$ pty-prompt step y # stage this hunk, show the next
$ pty-prompt step n # skip the next one
$ pty-prompt step s # split a hunk
$ pty-prompt step q # quit
$ pty-prompt stop # clean up
Wait for a non-interactive build
$ pty-prompt start --session build -- nix build .#thing -L
$ pty-prompt wait --session build # blocks until the build exits
$ pty-prompt stop --session build
Two concurrent sessions
$ pty-prompt start --session rebase -- git rebase -i HEAD~5
$ pty-prompt step --session rebase x
$ pty-prompt stop --session rebase
Run a REPL
$ pty-prompt start --timeout 1500 -- python3 -q
$ pty-prompt step --timeout 1000 "1 + 41"
$ pty-prompt step "exit()"
$ pty-prompt stop
FILES
$XDG_RUNTIME_DIR/pty-prompt/<session>.sock
Unix-domain socket on which a session daemon listens. Falls back to a directory under
TMPDIRwhenXDG_RUNTIME_DIRis unset.
$XDG_RUNTIME_DIR/pty-prompt/<session>.sock.log
Standard error of the detached daemon for that session.
ENVIRONMENT
XDG_RUNTIME_DIR
Base directory for session sockets. When unset,
TMPDIR(or the system temporary directory) is used instead.
EXIT STATUS
The pty-prompt utility exits 0 on success, and >0 if an error occurs.
A client command exits non-zero when it cannot reach the named session (for example, no daemon is running) or when the daemon reports an error.
INSTALLATION
Build and install using Nix flakes:
$ nix profile install git+https://codeberg.org/Profpatsch/Profpatsch?ref=canon#pty-prompt
This installs pty-prompt and this man page to your Nix profile.
IMPLEMENTATION NOTES
The daemon's lifecycle hangs off a
context.Context
cancelled by a
stop
or
kill-session
request or by
SIGTERM/SIGINT,
which deterministically tears down the accept loop, the PTY read loop, and the
child process. After the child exits, the daemon stays up so a final
step
can still retrieve the last output and the exit result; a subsequent
stop
then removes the session. The daemon's standard error is redirected to a
per-session log file, so a caller that pipes
start
does not block waiting for the long-lived daemon to close the inherited stream.
The output buffer is capped at the last 1 MiB to bound memory on chatty
programs.
SEE ALSO
git-add(1), pty(7), tmux(1)
AUTHORS
Profpatsch
pty-prompt
was created with
Claude Code.
CAVEATS
-
Completion detection is a timing heuristic; a program that pauses mid-output for longer than --timeout will appear settled prematurely. Raise the timeout for slow or bursty programs.
-
With --raw, full-screen TUIs emit cursor-movement and redraw escape sequences that are faithful but hard to read.
-
Only the last 1 MiB of output is retained; very chatty programs lose earlier output.