- Book: AI That Acts
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Command did not complete within its 120s timeout and was moved to the background. That is what Claude Code hands back after two minutes, the Bash tool's default timeout, along with the path of the file the output is going to. The command was an import script that prints a line every second when you run it by hand: step 1, step 2, step 3.
Claude reads that file and finds it empty. The script wasn't stuck. It called print for every line, and those lines are sitting in a buffer inside the process.
The C library and Python each pick a buffering mode for stdout from one check, isatty(), and under an agent that check fails.
Line-buffered on a terminal, block-buffered everywhere else
The rule is old and written down. From glibc's setvbuf(3): "Normally, all files are block buffered. If a stream refers to a terminal (as stdout normally does), it is line buffered. The standard error stream stderr is always unbuffered by default." The FreeBSD page says the same thing in nearly the same words.
Line-buffered means every \n sends the line out. Block-buffered means output collects in memory until the buffer fills, the program calls fflush, or the program exits normally. exit(3) is where "all open stdio(3) streams are flushed and closed."
Python makes the same choice in its own I/O layer. The sys docs: "When interactive, the stdout stream is line-buffered. Otherwise, it is block-buffered like regular text files." Since 3.9, stderr is line-buffered either way.
Node doesn't follow this rule. Its note on process I/O says writes to pipes are asynchronous on POSIX and synchronous on Windows. The Node trap is a different one: call process.exit() while a write is still pending and that output can be lost.
An agent's shell tool captures what your command prints so it can hand the text to the model. Whether the capture is a pipe or a file, it isn't a terminal. An open feature request asks for PTY support in Claude Code's Bash tool because interactive commands hang there, and the thread confirms the tool runs commands without a controlling terminal. So the moment an agent runs your script, it switches to block buffering.
Reproduce it in a minute
Ten lines of C:
#include <stdio.h> #include <unistd.h> int main(void) { for (int i = 1; i <= 5; i++) { printf("step %d\n", i); sleep(1); } return 0; } Build it with cc -o tick tick.c. Run ./tick and the five lines come out one second apart. Run ./tick | cat and nothing shows for five seconds, then all five lines land at once when the program exits and flushes, because cat took the terminal away from its stdout.

Same program, same lines. On a terminal they leave one at a time; on a pipe they wait for the buffer.
The Python version behaves the same way:
import time for i in range(1, 6): print(f"step {i}") time.sleep(1) python3 tick.py streams. python3 tick.py | cat waits, then dumps.
The case closest to a tool timeout uses timeout(1), which on Linux sends SIGTERM when time runs out:
timeout 3 python3 tick.py | cat You get no lines at all, even though print ran three times. They were in the buffer when SIGTERM arrived. Python's signal docs list only SIGPIPE and SIGINT among the handlers it installs by default, so SIGTERM ends the process without the flush a normal exit would do. The C program loses its lines the same way.
To see what your agent gives a command, ask it to run this, then run it in your own terminal and compare:
[ -t 1 ] && echo "tty" || echo "not a tty" -t 1 is true when file descriptor 1, stdout, "is opened on a terminal" (test(1)).
What Claude Code does with a quiet command
Claude Code's tools reference lays out the path. The Bash tool runs each command in a separate process and "streams a command's output to a working file as the command runs." When the command finishes, Claude Code reads that file back. The foreground timeout is two minutes by default (BASH_DEFAULT_TIMEOUT_MS). Claude can ask for more on a call, up to a ten-minute ceiling (BASH_MAX_TIMEOUT_MS), per the environment variables reference.
When a foreground command hits its timeout, Claude Code doesn't stop it. It moves the command to the background (unless it starts with sleep) and returns that "moved to the background" message with a task ID and the output file's path. If the command block-buffers, the file holds only what has been flushed so far, which can be nothing. The model ends up reading a progress log the program hasn't written yet.

The output file the agent reads, and the buffer the lines are actually in.
It bites in two more places:
- With
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1or in bare mode, a command that reaches its timeout "stops instead." If that stop ends the process with a signal, the buffered lines go with it, exactly like thetimeout 3demo. - The Monitor tool runs a script in the background and "receives each output line as it arrives," with a five-minute default deadline. A block-buffering watcher hands it lines in bursts, or only at exit, so Claude reacts late, or not before the deadline.
The fixes, cheapest first
Python: turn the buffer off. Run python3 -u script.py, or set PYTHONUNBUFFERED=1, which "is equivalent to specifying the -u option" (command-line docs). For one call site, print(..., flush=True) forces the flush (print()).
Make it stick for the agent. Claude Code's settings have an env key that sets variables "for every session and its subprocesses" (settings reference). Put it in the project's .claude/settings.json. Most committed env values, PYTHONUNBUFFERED among them, apply once each teammate trusts the folder.
{ "env": { "PYTHONUNBUFFERED": "1" } } Your own C code: say what you want. Call setvbuf(stdout, NULL, _IOLBF, 0); at the top of main and stdout is line-buffered wherever it points. Or call fflush(stdout) after each progress line.
On Linux, someone else's binary that uses C stdio: stdbuf -oL. Stock macOS doesn't ship it. From GNU coreutils' stdbuf(1): "If MODE is 'L' the corresponding stream will be line buffered." stdbuf -oL ./tick | cat streams again. The same page names the limits: a command that sets its own buffering (tee, for one) overrides it, and filters like dd and cat "don't use streams for I/O." Python buffers in its own I/O layer, so use -u there.
Last resort: hand it a terminal with script. With util-linux, script -q -c "./tick" /dev/null. The BSD script(1), as on macOS, takes the file first: script -q /dev/null ./tick. The command now sees a pseudo-terminal and line-buffers. It may also switch on colors, progress bars and prompts. The util-linux man page says "It is not recommended to run script in non-interactive shells" and warns against using it in pipes, so save it for one stubborn binary.
If you build the shell tool yourself
A homemade run_command tool builds the same pipe. Node's child_process.spawn defaults stdio to ['pipe', 'pipe', 'pipe'] (docs), so every child sees a stdout that isn't a terminal. Set the environment in the tool and read output as it arrives:
import { spawn } from "node:child_process"; export function runCommand(cmd: string): Promise<string> { return new Promise((resolve, reject) => { const child = spawn("sh", ["-c", cmd], { env: { ...process.env, PYTHONUNBUFFERED: "1" }, }); let out = ""; child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (out += d)); child.on("error", reject); child.on("close", () => resolve(out)); }); } PYTHONUNBUFFERED covers Python children. For C programs on Linux, prefix the command with stdbuf -oL. The data handlers fire when the child writes, and that's the reason to care: a tool that wants to report partial output, or tell the model "still running, last line was X", needs the child to write as it goes.
Hermes gives the agent a terminal. Its commands still don't get one
I build Hermes IDE, so weigh this section with that in mind. Hermes runs every agent "in a real terminal by default, running the agent's own interface" (README). Claude Code's own stdout there is a terminal, which is why its screen, colors and prompts look the way they do anywhere else.
The commands Claude Code runs are its child processes, and the Bash tool decides where their output goes: the working file from the docs above. Launching the agent in Hermes or in any other terminal app doesn't change that, because the agent's terminal belongs to the agent. Given those docs, expect the [ -t 1 ] check to come back not a tty from the Bash tool wherever you started claude.

Hermes 2.3.4: Codex on orders-types and Claude Code on orders-api side by side, each in its own hermes/ worktree, with the sessions sidebar (status, context use, cost, branch) on the left.
What Hermes helps with is the comparison. Split panes put sessions side by side, and a plain shell opens in a real terminal just like an agent does. Run the slow command yourself in a shell next to the agent. If it streams there and stays silent under the agent, you're looking at buffering, not a hang, and you know before you spend a turn asking the agent to debug a script that works. Add PYTHONUNBUFFERED to the project settings and let the agent run it again.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. Download it or read the code on GitHub. It isn't affiliated with Nous Research, Anthropic, OpenAI or any agent vendor.
If this was useful
The shell tool is usually the first tool an agent gets, and those details decide whether the model has anything to reason about. My book AI That Acts covers the tool layer in TypeScript: tool calling, functions, and a first working agent.
It is book 3 of AI in TypeScript, a five-book series that runs from your first LLM call to agents in production.



