claude-tty-fix.zsh: a precmd heal, always-on debug logging, and on-demand MCPCommit b57a130 — the shell-side survivors of a three-bug hunt into why
claude kept “crashing” and spewing mouse-command garbage in the terminal.
What this file is. claude-tty-fix.zsh is a small zsh file symlinked into
Oh-My-Zsh’s custom/ directory, so every interactive shell auto-loads it. It defines a
claude() shell function that wraps the real claude binary to work around a
known Claude Code terminal bug.
The bug it already fixed. When you launch claude and start typing during its
~1–5 second startup, the process can be hit by SIGTTIN — the
“suspended (tty input)” job-control stop that happens when a process reads the terminal while it
isn’t the terminal’s foreground process group. Claude enables mouse tracking
(\e[?1003h) at startup; if it freezes mid-init, that mode is never turned off, so the terminal
starts echoing every mouse move as an escape sequence:
35;48;12M35;47;12M35;43;13M35;39;14M35;35;15M 997;1n
How the pre-existing claude() handled it — two levers:
/dev/ttyThat combination is real and it works — but only for the failure modes where claude
eventually returns to the function. That gap is what this change closes.
The heal code sits on the line after command claude. A shell function is a straight
line of execution — it cannot reach that line until command claude finishes. And there’s the
catch: a process that is suspended (stopped by SIGTTIN) has not finished. It’s
frozen, still alive. So the function stays blocked, the reset never fires, and the garble stays on screen at
your next prompt.
zsh runs a precmd hook right before it draws each prompt. If we disable mouse tracking there,
the garble is cleared on your next Enter — no matter how the previous claude
died, suspended or not. It’s a safety net stretched under the whole thing, independent of the function’s
control flow.
/dev/ttyPick an ending and watch which cleanup path fires. Notice that only the precmd heal survives
the suspend case.
Always-on debug logging. The wrapper now passes --debug-file so every launch
writes a timestamped trace to ~/.claude/logs/ (auto-pruned to the last 20). This is what let the
investigation catch a failure faithfully — in the real shell, under the same nomonitor,
rather than through a script surrogate whose pseudo-terminal removes job control and hides the
very SIGTTIN we were chasing.
On-demand MCP servers. Only ~9 broadly-useful MCP servers stay in the always-on global
config; the niche ones (RAG, travel, sports, media) moved into ~/.claude/mcp-optional/*.json and
load only when you ask, via --mcp-config (which merges them into that one session):
_claude_mouse_heal() {
[[ -t 2 ]] || return # only heal a real terminal
printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?25h' > /dev/tty 2>/dev/null
}
autoload -Uz add-zsh-hook 2>/dev/null && add-zsh-hook precmd _claude_mouse_heal
It emits only mouse-disable and show-cursor escapes — deliberately not bracketed-paste
(?2004l), which zsh itself relies on, so it can’t disturb normal pasting. These sequences are
invisible no-ops when mouse mode is already off, so running them before every prompt costs nothing.
--debug-file with self-pruning logslocal _dbg=""
if [[ -z "${CLAUDE_DEBUG_LOG_OFF:-}" ]]; then
mkdir -p ~/.claude/logs 2>/dev/null
_dbg=~/.claude/logs/claude-$(date +%Y%m%d-%H%M%S)-$$.log
for f in ~/.claude/logs/claude-*.log(NOm[21,-1]); do rm -f -- "$f"; done
fi
[[ -n "$_dbg" ]] && command claude --debug-file "$_dbg" "$@" || command claude "$@"
The pruning is a single zsh glob qualifier: (NOm[21,-1]) means Nullglob,
Order descending by mtime (newest first), then take elements [21,-1] — i.e.
everything past the 20 newest — and delete them. No ls | tail pipeline, no subprocess.
A subtle correctness note: the timestamp uses date, because zsh’s printf has no
%(...)T strftime directive (that’s a bash-only feature — the first draft mis-used it).
claude-rag() { claude --mcp-config ~/.claude/mcp-optional/rag.json "$@"; }
claude-travel() { claude --mcp-config ~/.claude/mcp-optional/travel.json "$@"; }
claude-extras() { claude --mcp-config ~/.claude/mcp-optional/*.json "$@"; }
Each calls claude — the function, not command claude — so an on-demand
session still gets nomonitor and the self-heal. They simply prepend a --mcp-config
file that merges the niche servers back in for that session only.
claude-debug — the A/B probe that cracked itclaude-debug() {
local dbg=~/.claude/logs/claude-RAW-$(date +%Y%m%d-%H%M%S)-$$.log
command claude --debug-file "$dbg" "$@" # RAW: bypasses the nomonitor wrapper
}
This runs the raw binary (no nomonitor) so you can compare: does the failure need the
wrapper, or happen on the bare binary too? Running it and watching it suspended (tty input) —
while the wrapped claude did not — is what proved nomonitor was doing its job.
The comment also records a hard-won lesson: don’t wrap this in script, whose pty strips the job
control the bug depends on.
Five questions on the reasoning behind the change. Click to reveal.
/explain-diff walkthrough of commit b57a130 ·
linux_configuration/zsh/claude-tty-fix.zsh. Full root-cause writeup lives in the session memory
notes (reference-claude-startup-tty).