Codex 'suspended (tty input)' on macOS: stdio MCP servers keep the tty
Contents
A few minutes into using Codex, zsh: suspended (tty input) kept dropping me back to the shell.
I’d left it running overnight with the goal command, and by morning it had died on the same error.
Honestly, goal is pointless like this.
I was running it with --dangerously-bypass-approvals-and-sandbox, so I suspected the flag first, then poked at it on an M4 Mac mini with a repro, the source, and the live process tree.
Setup
| Item | Detail |
|---|---|
| Machine | M4 Mac mini (macOS 15.5, Darwin 25.5.0, arm64) |
| Codex | codex-cli 0.150.1 (Homebrew @openai/codex, auto-updated midday) |
| Shell / terminal | zsh 5.9, Ghostty (TERM=xterm-ghostty) |
| stdio MCP | backlog (docker run -i), qwen-mm-plugins-core (uvx), codex_apps, and the internal code-mode-host |
Reproducing it on a pty
suspended (tty input) is what you get when a process group that isn’t the terminal’s foreground tries to read terminal input and gets stopped by SIGTTIN (exit code 128 + 21 = 149).
Plain Codex is hard to pin down, so I reproduced it small on a pty (pseudo-terminal).
A TUI-role process starts a child three ways and has it read /dev/tty.
| How the child is started | Controlling terminal | When the child reads the terminal |
|---|---|---|
| Same process group as the parent | Shared | Reads fine (no stop) |
Its own process group (setpgid) | Still shared | Stopped by SIGTTIN (ps state T) |
New session (setsid) | Detached | No terminal; the read fails (no stop) |
Only the middle one stopped.
A child moved into its own process group drops out of the terminal’s foreground.
The controlling terminal is still shared with the parent, so reading /dev/tty gets it stopped by SIGTTIN.
ps shows T (stopped), and zsh prints suspended (tty input).
A child that went through setsid moves to a session with no controlling terminal, so reading /dev/tty fails with an error instead of stopping.
In my runs, splitting off the process group alone made it stop, and taking it all the way to setsid did not.
How shell tools and MCP get started
Lining up how Codex 0.150.1 starts its children, in the source:
| How the child is started | Terminal detach | Fix |
|---|---|---|
Shell-tool command exec (core/src/spawn.rs) | detach_from_tty() (setsid) | PR #9477, January 19, 2026 |
Shell snapshot capture (shell_snapshot.rs) | setsid, stdin to /dev/null | PR #9477 and PR #9735 (January 30, 2026) |
exec-server pipe children (utils/pty) | detach_from_tty() | PR #9477 |
Local stdio MCP server (rmcp-client/src/stdio_server_launcher.rs) | process_group(0) only | Not fixed |
detach_from_tty() is setsid(), falling back to setpgid only when that fails with EPERM.
The first fix was PR #8691 (January 8, 2026), which stopped calling setpgid on inherited-stdio children, after an Elixir command hung on the same SIGTTIN.
Then #9477 added setsid to the shell-tool and snapshot paths, and #9735 sent the snapshot’s stdin to /dev/null. All three landed in January 2026.
Only the spot that launches local stdio MCP servers just moves them into their own process group with command.process_group(0) and never detaches the session.
It’s the same on both the old and new MCP protocol branches, so the middle case from the pty repro is still there.
This is still an open issue (openai/codex #18656 Stdio MCP servers can trigger terminal job-control suspension during startup on macOS, labels bug and mcp, open).
The reporter writes that even in a separate process group the child keeps the controlling terminal, so if something in that tree does terminal I/O, zsh stops it.
They propose detaching with setsid() or TIOCNOTTY.
The MCP process tree on my machine
The MCP server itself has stdin and stdout on pipes, so it doesn’t use the terminal.
If anything reaches for the terminal it’s the descendants, and backlog spawns its children like this:
codex(TUI) ── docker run -i ── (backlog-mcp-server container)
└─ uvx ── git / python (fetching qwen-mm-plugins-core)
└─ code-mode-host
With ps, every MCP child still held the terminal it launched from, ttys025, in a process group separate from Codex.
If anything in that tree reads /dev/tty, that’s where SIGTTIN fires.
Which server and which call actually does it, I didn’t chase down one by one this time.
Issue #18656 doesn’t narrow it to a specific server either.
Another Codex on the same machine, sitting idle under -s read-only (sandboxed, no bypass), started backlog through npx, and its child was still in its own process group, sharing ttys001 with Codex.
The stdio_server_launcher.rs that starts MCP takes no sandbox or approval settings, so the bypass flag makes no difference.
Issue #18656’s repro is just “start Codex normally” too; bypass never comes up.
98 leftover containers
A stopped Codex can’t run its cleanup, so its MCP children stay alive.
On the machine I found two Codex processes in T (stopped):
$ ps -axo pid,pgid,stat,tty,command | awk '$3 ~ /T/'
6185 6185 T ttys004 node .../codex --dangerously-bypass-approvals-and-sandbox
17900 17900 T ttys030 node .../codex --dangerously-bypass-approvals-and-sandbox
One started on August 25, the other earlier today.
Both were holding their MCP subtree (docker run, uvx, python, code-mode-host) frozen.
backlog starts with docker run --rm, so the container is removed once it exits.
But when Codex stops it can’t tear MCP down, so the docker run and the container both stay up and --rm never removes anything.
There were 98 backlog-mcp-server containers, about 6.2 GiB in all. The oldest was from August 24, and Docker Desktop’s 8 GiB allocation was nearly full.
$ docker ps --filter ancestor=ghcr.io/nulab/backlog-mcp-server -q | wc -l
98
The 98 is what I measured in this environment; I don’t think every Codex necessarily ends up this way.
The more stopped Codex processes pile up, the more unreleased processes stack up.
Workarounds
Right after it stops, jobs -l for the number and fg brings it back.
That only clears the stop; it stops again under the same conditions.
To make it happen less often, pull stdio MCP servers one at a time and see which one is the cause.
A comment on Issue #9493 has a user-side mitigation that wraps Codex and ignores SIGTTIN with trap '' TTIN, but that only prevents the stop, it doesn’t fix it.
An older version (0.46.0) also had a bug where pressing Ctrl+T mid-prompt drops into the same stop (Issue #5195, closed).
For that one, a comment says adding bindkey -r '^T' to .zshrc keeps it from happening.