`claude agents --json` unconditionally restores a startup termios snapshot on exit, reverting another program's raw mode
Environment
Claude Code 2.1.237 (identical on 2.1.234, 2.1.235, 2.1.236) · Anthropic API · WSL2 Ubuntu, kernels 5.15 and 6.18 · Windows Terminal + zsh · the repro below runs on a bare openpty(), with no terminal emulator, multiplexer or fullscreen renderer involved.
Bug
claude agents --json is documented as printing "active sessions as a JSON array and exits". While it runs it also puts stdin's tty into raw mode — ICANON, ECHO and ISIG off — for ~400 ms of its ~0.5 s run, and on exit writes back a termios snapshot it took at startup.
The restore is unconditional: it replays that snapshot whether or not the current settings are the ones this process established. Any program that took the terminal during those 400 ms is silently reverted, on a tty this command was never asked to drive.
No preconditions — it reproduces with a fresh CLAUDE_CONFIG_DIR and zero sessions, where the entire output is [].
Repro
jq --version is the control: same process shape, same pty, never reverts. The harness calibrates to the command's runtime on your machine, so a faster or slower build still lands the delays on both sides of the boundary.
import os, subprocess, termios, time
CMD = ["claude", "agents", "--json"]
def trial(cmd, delay):
m, s = os.openpty()
a = termios.tcgetattr(s); a[3] |= termios.ICANON | termios.ECHO
termios.tcsetattr(s, termios.TCSANOW, a) # cooked, as a pty starts
p = subprocess.Popen(cmd, stdin=s, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True)
t0 = time.time()
time.sleep(delay)
a = termios.tcgetattr(s); a[3] &= ~(termios.ICANON | termios.ECHO)
termios.tcsetattr(s, termios.TCSANOW, a) # another program takes the tty
p.wait(); dur = time.time() - t0
reverted = bool(termios.tcgetattr(s)[3] & termios.ICANON)
os.close(m); os.close(s)
return reverted, dur
run = trial(CMD, 0)[1] # calibrate to this machine
delays = [0.002, run * .25, run * .5, run * 1.5, run * 2.5]
print(f"claude agents --json runs {run:.3f}s here; taking the tty at "
+ ", ".join(f"{d*1000:.0f}ms" for d in delays))
for cmd in (CMD, ["jq", "--version"]):
print(f"{cmd[0]:<7}", [("REVERTED" if trial(cmd, d)[0] else "ok") for d in delays])
- Save as
repro.py python3 repro.py
Actual:
claude agents --json runs 0.476s here; taking the tty at 2ms, 119ms, 238ms, 714ms, 1189ms
claude ['REVERTED', 'REVERTED', 'REVERTED', 'ok', 'ok']
jq ['ok', 'ok', 'ok', 'ok', 'ok']
Expected: both rows all ok. A command that prints JSON and exits should not write termios on a terminal it does not own.
The boundary sits exactly on the runtime: take the tty while the command is alive and the setting is reverted; take it after it has exited and the setting survives.
Ruled out
- Not the emulator, multiplexer or renderer — bare
openpty(), nothing else attached. - Not exit-time only. Sampling
c_lflagevery 2 ms through one run: cooked → raw at 0.086 s → cooked at 0.491 s. Raw is held for the whole run, ISIG included, so Ctrl-C is dead during it. - Not a lost or empty snapshot. Taking the tty at 0 ms — before the command's own
tcgetattr, so it captures raw — gives 0/6 reverted; 2 ms and every point through 200 ms gives 6/6. It restores exactly what it captured, which is why in a real pipeline the outcome turns on a few-millisecond race between the two calls. - Nothing documented covers it. agent-view specifies
agents --jsonas print-and-exit and documents no terminal handling; the CLI reference lists no environment variable that suppresses terminal setup for non-interactive subcommands.
Impact
Any claude agents --json running beside a curses/TUI process on the same tty. Left in canonical mode that program blocks in read() until Enter while the line discipline echoes every keystroke — Escape renders as a literal ^[, arrows as ^[[A — and since nothing signals a termios change, it cannot notice or recover.
I hit it through craftzdog/tmux-claude-session-manager, whose picker runs agents.sh | fzf; roughly one launch in three the picker comes up completely unresponsive. That frequency is scheduler-dependent — the deterministic revert above is the report.
Suggested fix
Don't call tcgetattr / tcsetattr on stdin for agents --json at all: it emits JSON and exits, so it has no interactive surface to configure. Failing that, make the restore conditional — write the snapshot back only if the current termios still matches what this process set. That alone closes the race.
Regression, and what I could not test
- Regression: I don't know. Identical on 2.1.234 through 2.1.237 (3/3 each); no older build available to bisect against.
- macOS and native Linux untested — both my machines are WSL2. The harness is pure POSIX termios with no WSL-specific surface, so I would expect
REVERTEDanywhere. If it printsokon macOS then my platform scoping is wrong and I would like to know. Only the hit rate looks scheduler-sensitive: my second machine lost the race 0/20 at zero delay, and 4/4 once fzf's start was nudged 20 ms later. - I did not determine what the 400 ms raw window is for, and I only tested
agents --json.
Related, and why this isn't a duplicate of either
Searched termios, tcsetattr, stty, raw mode and agents --json; nothing covers the non-interactive path.
- #84029 — restore handler registered on the graceful-exit path, returning early unless stderr is a TTY. Same subsystem, opposite failure: there it cannot fire when it should, here it fires when it must not.
- #57254 —
tcsetattrfrom a non-foreground process group suspending the CLI on macOS. Different symptom and path, but confirms the CLI writes termios on stdin without checking whether it is entitled to.