A test runner streams progress in your terminal, then appears to freeze when an agent captures its output. A remote command works after an interactive SSH login but fails when you run it directly. Adding -t makes one symptom disappear and introduces carriage returns into a JSON response.
These problems often start at the same boundary: a terminal is an interface with behavior; a pipe carries bytes. Programs can detect that difference and change buffering, prompts, colors, and output format. The terminal driver adds its own behavior too.
This guide gives you a small experiment to reproduce the difference, a diagnostic sequence for SSH and subprocess failures, and a way to decide which interface your automation actually needs. The examples target macOS and Linux with Python 3.9 or later. Remote examples assume OpenSSH and a POSIX-compatible remote shell.
First, inspect all three streams
A process normally starts with three standard file descriptors: 0 for input, 1 for output, and 2 for errors. Each can point to a different kind of object. Checking only whether you launched the process from a terminal misses that distinction.
Save this as tty_probe.py in a scratch directory:
import sys
import time
print(
f"stdin={sys.stdin.isatty()} "
f"stdout={sys.stdout.isatty()} "
f"stderr={sys.stderr.isatty()}",
file=sys.stderr,
flush=True,
)
for step in range(4):
print(f"step {step + 1}/4")
time.sleep(0.4)
Run these commands individually from an interactive terminal:
python3 tty_probe.py
python3 tty_probe.py | cat
python3 -u tty_probe.py | cat
python3 tty_probe.py > run.log 2> run.err
The diagnostic line goes to stderr so piping stdout does not hide it. In the last command, inspect run.err and run.log after the process finishes.
| Invocation | stdin / stdout / stderr are terminals | Expected progress |
|---|---|---|
| Direct execution | true / true / true | One line about every 0.4 seconds |
Output piped to cat |
true / false / true | Four lines together near the end |
| Unbuffered Python, output piped | true / false / true | Lines arrive during execution |
| Output and errors redirected to files | true / false / false | Progress is typically written at exit |
These values assume the surrounding shell has terminal input and no additional redirections. Run the same commands inside another wrapper and the result may change. isatty() tells you whether a particular descriptor refers to a terminal; it does not tell you whether a person is present. See the isatty manual.
The delayed output is generated by Python, not held back by cat. Python normally line-buffers stdout when interactive and block-buffers it otherwise. A newline flushes the first case; it does not necessarily flush the second. Python 3.9 and later line-buffer stderr in both cases. These rules are documented under Python's standard streams.
What a pseudoterminal actually changes
A terminal emulator talks to a pseudoterminal, or PTY: a pair of endpoints traditionally called master and slave. The application uses the terminal-facing endpoint; the emulator reads output and writes input through the other endpoint. That lets software provide the interface expected by interactive shells and terminal programs. The PTY manual describes this arrangement.
There are two separate sources of behavior:
- The program chooses a presentation. It may enable a progress bar, ask a question, or switch from JSON to a human-readable table after checking its streams.
- The terminal driver processes input and output. Depending on its settings, it can echo input, buffer input until Enter, interpret control characters, and translate line endings.
For example, ICANON controls canonical, line-oriented input; ECHO controls input echo; ISIG enables special-character signal handling; and ONLCR can translate outgoing newline into carriage-return/newline. These are independent settings in the termios interface.
A pipe does not implement those terminal settings. Setting TERM=xterm-256color does not create a PTY either. It describes terminal capabilities to programs that consult it; it does not change what the file descriptors are connected to.
This distinction explains why forcing a terminal can fix delayed logs while breaking a parser. You changed the execution interface as well as the buffering decision.
Fix delayed output at the producer
When output needs to stay machine-readable, first use the program's documented streaming or unbuffered mode. For the Python example, either run python3 -u tty_probe.py or change the progress line to:
print(f"step {step + 1}/4", flush=True)
Python's -u option makes stdout and stderr unbuffered; it does not change stdin. Flushing individual records gives more control when some output should remain buffered.
Before changing your wrapper, identify where output stops:
| Observation | First thing to inspect |
|---|---|
| Nothing arrives until process exit | Producer buffering or a wrapper that captures the entire result |
| Some records arrive, then everything stalls | An unread stream, a blocked consumer, or application work |
| Bytes arrive but no complete lines appear | A line-based reader waiting for \n |
| Progress produces repeated fragments | Carriage-return updates intended to redraw a terminal line |
| A command waits only when automated | A prompt on stdin or an attempt to open the controlling terminal |
Keep the experiment small. Replacing the real command with tty_probe.py tells you whether your runner can deliver incremental output at all. If the probe streams correctly, investigate the real program's own output and prompt settings next.
SSH: request a terminal deliberately
With a normal interactive client, ssh dev.example.com starts an interactive remote session. Supplying a remote command changes the default: SSH does not normally allocate a remote PTY for it. -T explicitly disables allocation; -t requests it; repeating -t forces the request even when the local client has no terminal. These behaviors are specified by OpenSSH's ssh manual.
Use explicit modes while diagnosing a problem:
# Replace the example hostname with a host you control.
ssh -T dev.example.com 'test -t 0 && echo tty || echo pipe'
ssh -t dev.example.com 'test -t 0 && echo tty || echo pipe'
From an interactive terminal, the first should print pipe and the second tty. The server can still refuse PTY allocation. A forced request does not override server policy.
For a bounded machine-readable command, preserve separate streams and disable prompts:
ssh -T -o BatchMode=yes dev.example.com \
'python3 -c "import json; print(json.dumps({\"ok\": True}))"' \
> result.json 2> ssh-errors.log
This example assumes working key-based authentication and an already verified host key. BatchMode=yes makes authentication prompts fail instead of waiting for input; it does not stop the remote application from asking its own questions. The command also still passes through the remote shell, so avoid constructing it by concatenating untrusted strings.
Use a PTY for an interactive editor, shell, or program that actually needs terminal behavior. For JSON, archives, and other structured output, keep it disabled. With the usual remote PTY arrangement, stdout and stderr share the terminal stream, so local redirection can no longer separate the remote application's diagnostics from its data. SSH's own client diagnostics remain a separate concern.
If machine-readable SSH output contains login banners or shell startup messages even without a PTY, that is a different problem. Diagnose it with the SSH startup-output guide rather than adding terminal emulation.
A subprocess can hang even when buffering is correct
Suppose a parent process captures both stdout and stderr, then reads stdout to completion before touching stderr. If the child fills the stderr pipe, it blocks waiting for the parent. The parent is waiting for stdout to end, which the blocked child never reaches.
For commands with bounded output, Python's subprocess.run() handles communication on both captured streams:
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-u", "tty_probe.py"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=10,
check=True,
)
print(result.stdout, end="")
print(result.stderr, end="", file=sys.stderr)
This is a completed-result API: the parent prints the output after the child exits, even though the child is unbuffered. It is appropriate for a short diagnostic, not a live log viewer.
For long-running output, drain both streams concurrently with an async subprocess API, reader threads, or an event loop. Preserve the stream identity if a parser consumes stdout. Use bounded buffers or spool logs to disk rather than accumulating unlimited output in memory. Python documents both the pipe deadlock and the memory implications in its subprocess guidance.
Merging stderr into stdout reduces the number of streams to drain, but it also discards a useful boundary. That is reasonable for a human-readable build log and usually wrong for a JSON-producing tool.
Control-C, process exit, and command completion
Writing the byte 0x03 into a pipe is not equivalent to sending SIGINT. In a terminal with signal-generating input enabled, the configured interrupt character normally causes a signal to the foreground process group. In raw mode, the application may receive that byte directly.
An automation runner therefore needs an explicit interruption policy. Is it sending terminal input, signalling one process, or stopping a process group? Is the command local, or is a remote job still running after the local SSH client exits? Treat those as separate operations.
Likewise, an idle terminal is not proof that a command finished. It may be waiting for input, sleeping, or doing work without printing. For a subprocess, collect its exit status. For a command inside a persistent shell, use a reliable command-completion mechanism instead of matching the appearance of a prompt.
If a tool loses the result of a command that changes state, do not assume rerunning it is harmless. The guide to idempotent MCP tools explains how to preserve an operation's identity and recover an uncertain outcome.
Pipelines add another trap. This command can report success even though Python exits with an error, because the default pipeline status in Bash comes from its last command:
python3 -c 'raise SystemExit(7)' | cat
echo "$?"
For a Bash script that needs upstream failures to fail the pipeline, use set -o pipefail. Consult the Bash pipeline documentation for the exact status rules. Be deliberate around consumers such as head that intentionally close a pipe early.
Choose the interface before adding a workaround
For a CLI you own, make human and machine behavior explicit. Provide a stable structured-output mode, keep diagnostics on stderr, offer a noninteractive mode, and document how progress is flushed. An --output=json option should not silently turn into a decorated table because someone attached a terminal.
For a runner you own, use pipes for structured commands and PTYs for interactive applications. If you implement a PTY runner, account for terminal size, resize events, input modes, output limits, interruption, and process cleanup. Merely connecting descriptors to a PTY does not establish a complete interactive shell environment with correct job control.
In Agents UI, keep an interactive terminal beside a separate session that runs the piped or SSH reproduction. Compare the descriptor results first, then the bytes, then the exit status. That turns an intermittent-looking agent failure into a concrete difference you can reproduce and fix.
Continue with OSC 8 terminal hyperlinks if your problem is clickable output, or persistent terminal sessions if the problem is surviving a disconnect. Neither behavior is guaranteed simply because a PTY exists.