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 @@ + + +Hardening claude-tty-fix.zsh — precmd heal, debug logging, on-demand MCP + + +
+ 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. +
  3. Intuition — why the old fix had a blind spot
  4. +
  5. Code — the four things this diff adds
  6. +
  7. Quiz — check your understanding
  8. +
+
+ + +

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). +
+
+ + diff --git a/docs/explanations/index.html b/docs/explanations/index.html index ffa0b5f7..6dd9caaa 100644 --- a/docs/explanations/index.html +++ b/docs/explanations/index.html @@ -3,7 +3,7 @@ -testsAndMisc — change explanations +explanations — change explanations