PTY vs Pipes
Understanding the difference between PTYs and pipes explains why interactive CLIs are hard to automate, and why shuck solves this at the right layer.
Why Agents Need PTY
When an agent runs a command through a normal pipe, many tools detect the pipe and change behavior. An agent running psql via pipe gets a stripped-down non-interactive mode. An agent running brew doctor via pipe may get different output than a human sees. The agent needs the tool to behave exactly as it would for a human — and that requires a PTY.
shuck allocates a PTY so the child tool sees a real terminal, then strips the TUI artifacts before passing the clean text to the agent. The agent gets human-quality output without the ANSI noise.
What is a Pipe?
A pipe (|) connects the stdout of one process to the stdin of another:
echo "hello" | cat
Under the hood, the OS creates a pair of file descriptors. When a program calls isatty(fd) on a pipe, it returns false. Many programs check this and change their behavior:
lsoutputs one item per line instead of columnsgit logdoesn’t page outputpython3skips the REPL prompt and expects raw codemysqldrops to non-interactive mode and skips the prompt
This is why agents can’t reliably pipe interactive tool output — the tool detects the pipe and changes behavior.
What is a PTY?
A pseudo-terminal (PTY) is a kernel-provided abstraction that mimics a real hardware terminal. It has two ends:
- Master: held by the controlling program (shuck)
- Slave: given to the child process as its terminal
When a program calls isatty(fd) on the slave end, it returns true — because it IS a real terminal, just a virtual one. The program behaves exactly as if a human is at the keyboard.
Behavioral Differences
| Behavior | Pipe | PTY |
|---|---|---|
isatty() returns | false | true |
| Colors / ANSI output | Usually disabled | Enabled |
| Progress bars / spinners | Usually disabled | Enabled |
| Interactive prompts | Disabled | Enabled |
| Line buffering | Block buffering | Line buffering |
Terminal size (TIOCGWINSZ) | Returns zeros | Returns configured size |
COLUMNS / LINES | Not set | Set to PTY dimensions |
The shuck Approach
shuck uses a PTY by default so the child process behaves interactively, then strips the TUI artifacts before they reach the agent:
Child (thinks it's in terminal)
→ writes colored, animated output to PTY slave
shuck reads from PTY master
→ strips ANSI sequences
→ writes clean text to stdout (which IS a pipe)
This means agents get the best of both worlds:
- The child program behaves correctly (interactive mode, full output)
- The agent gets clean, uncolored, unformatted text it can parse
When Pipes Are Used
shuck always uses a real PTY on Unix (including CI). Pipes are only used as a fallback on platforms without PTY support (Windows, until ConPTY is implemented). In pipe mode, ANSI stripping still runs (some tools emit ANSI even to pipes), but isatty() returns false for the child.
Line Buffering vs Block Buffering
One subtle difference: PTYs force line buffering on the child process’s stdout. Pipes use block buffering (typically 4KB or 8KB).
Since shuck always uses PTY on Unix, each line appears immediately:
# Each line appears immediately
shuck python3 -- "
import time
for i in range(5):
print(i)
time.sleep(1)
"