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:

  • ls outputs one item per line instead of columns
  • git log doesn’t page output
  • python3 skips the REPL prompt and expects raw code
  • mysql drops 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

BehaviorPipePTY
isatty() returnsfalsetrue
Colors / ANSI outputUsually disabledEnabled
Progress bars / spinnersUsually disabledEnabled
Interactive promptsDisabledEnabled
Line bufferingBlock bufferingLine buffering
Terminal size (TIOCGWINSZ)Returns zerosReturns configured size
COLUMNS / LINESNot setSet 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)
"