`claude agents --json` unconditionally restores a startup termios snapshot on exit, reverting another program's raw mode

Status Open
Reported on v2.1.237
Maintainer reply None cached
Activity 0 comments · opened Aug 20, 2026

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])
  1. Save as repro.py
  2. 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_lflag every 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 --json as 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 REVERTED anywhere. If it prints ok on 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.
  • #57254tcsetattr from 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.

View original on GitHub ↗