change explanation

Hardening claude-tty-fix.zsh: a precmd heal, always-on debug logging, and on-demand MCP

Commit b57a130 — the shell-side survivors of a three-bug hunt into why claude kept “crashing” and spewing mouse-command garbage in the terminal.

Contents
  1. Background — the wrapper and the bug it already fixed
  2. Intuition — why the old fix had a blind spot
  3. Code — the four things this diff adds
  4. Quiz — check your understanding

1 · Background

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:

setopt nomonitor
job control off → claude shares the shell’s foreground group → can’t take SIGTTIN
+
on-exit reset
after claude returns, write mouse/cursor-reset escapes to /dev/tty

That 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.

2 · Intuition

The blind spot: the reset runs after claude returns

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.

claude EXITS
command returns → next line runs → reset fires → clean ✔
claude SUSPENDS
command never returns → reset line unreachable → garble persists ✘

The fix: heal on every prompt, not on exit

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.

any claude death
exit · suspend · kill
→
you press Enter
→
precmd fires
mouse-disable → /dev/tty
→
clean prompt
no more garble

Play with it: how did claude end?

Pick an ending and watch which cleanup path fires. Notice that only the precmd heal survives the suspend case.

Terminal cleanup simulator

Two more riders in the same commit

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):

global core (~9)
dart · reflex · vestige · projectmem · i3wm · toolport · yay · servo-fetch · duckduckgo
+
claude-rag
--mcp-config rag.json
claude-travel
travel.json
claude-extras
*.json (all)

3 · Code

a. The precmd heal — a net under the whole function

_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.

b. Always-on --debug-file with self-pruning logs

local _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).

c. On-demand launchers that still inherit the fix

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.

d. claude-debug — the A/B probe that cracked it

claude-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.

4 · Quiz

Five questions on the reasoning behind the change. Click to reveal.

0 / 0
Generated as an /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).