diff --git a/docs/explanations/2026-07-18-explanation-claude-tty-fix-hardening.html b/docs/explanations/2026-07-18-explanation-claude-tty-fix-hardening.html new file mode 100644 index 00000000..03cf3a5e --- /dev/null +++ b/docs/explanations/2026-07-18-explanation-claude-tty-fix-hardening.html @@ -0,0 +1,407 @@ + + +
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).
+