Which Claude CLI Prompt Finished? Put a ✅ on the tmux Tab
Four Claude Code sessions in four tmux tabs look identical. I gave each tab a status light, then packaged it up: tmux-claude-status.
The problem
I usually have four or five Claude Code sessions running at once, one per tmux window. One is a long refactor I'm not waiting on, one is a quick question I want answered now, one is research I'll get to in ten minutes. Different shapes, different durations, finishing in an order I can't predict.
The bottleneck stopped being Claude a while ago. It's me noticing which window finished. Nothing in the tab bar separates a session sitting on an answer it produced four minutes ago from one that's still thinking, or from one that's been quietly blocked on a permission prompt since I walked away. So I'd cycle through all of them every couple of minutes to find out. Multiply that by a day and it's a real tax on attention.
What I wanted was boring: a mark on the tab of whichever window needs me, which goes away once I've dealt with it.
What I built
tmux-claude-status puts one badge on each tmux tab, driven by Claude Code's hooks.

| Tab shows | Meaning |
|---|---|
| (nothing) | Idle. Nothing running, nothing unread |
🔄 |
Prompt in flight, Claude is working |
❓ |
Blocked on you: a permission prompt, a question, or an MCP dialog |
📝 |
A plan is written and waiting for your approval |
⚠️ |
The turn died on an API error, so there's nothing to read and it needs retrying |
🌀 |
Idle between iterations of a loop, and will resume on its own |
⏳ |
Turn finished, but background shells or agents it started are still running |
✅ |
Turn finished, nothing running, ready to read |
Only the checkmark ever clears. The others describe live state, and looking at a window doesn't answer a question, approve a plan, finish a build, or end a loop.
Install is two halves. The renderer, via TPM:
set -g @plugin 'dop-amine/tmux-claude-status'And the hooks, as a Claude Code plugin:
/plugin marketplace add dop-amine/tmux-claude-status
/plugin install tmux-claude-status@tmux-claude-status- Site: dop-amine.github.io/tmux-claude-status
- Source: github.com/dop-amine/tmux-claude-status
- The hook contract: what Claude Code actually does, most of which isn't documented
It works on macOS and Linux, needs tmux 3.1+, and has no dependencies beyond POSIX shell.
What shipped after this was written
The post below is the original build. Since then the tool picked up a few things worth knowing about, most of them found by other people using it:
- 📝 for a plan awaiting approval. A returned plan sat on the working badge,
which claimed Claude was busy when it was waiting on a decision.
/planends by calling theExitPlanModetool, soPreToolUsecatches it exactly. - Background agents count as work. The ⏳ check walks the process tree for
background shells, and an agent runs inside the Claude process, so it has no
process to find. A window with an agent running three minutes showed ✅.
SubagentStartandSubagentStopmaintain a counter instead. - Failure events.
PostToolUsedoesn't fire when a tool fails andStopdoesn't fire when a turn dies on an API error. WithoutPostToolUseFailureandStopFailure, a failed tool strands ❓ and an API error strands 🔄. - 🌀 for a running loop. Between iterations a
/loophas no shells, no agents and an ended turn, so it reported ✅ while about to resume itself. Both mechanisms re-enqueue into the same session:ScheduleWakeupfor dynamic loops,CronCreatefor interval loops and/schedule. A loop ends via the sameScheduleWakeuptool withstop: true, so the hook reads the tool input rather than matching the name. - ⚠️ when a turn dies.
StopFailurefires instead ofStopon an API error, so a rate-limited session looked exactly like one with an answer waiting. - Two sessions in one window. State moved from the window to each pane and is aggregated, so split panes stop overwriting each other.
- It stopped trampling other people's tmux hooks, which is the bug I'm least
proud of: loading the plugin wiped every unrelated
session-window-changedhook on the server.
How I built it
The rest of this is the build: the approaches that don't work, the bugs that passed green tests, and the parts of Claude Code's hook system I had to reverse-engineer.
Worth stating the setup, because some of the wiring hangs off it: macOS, tmux from Homebrew, and the gpakosz Oh my tmux! config with every personal override confined to ~/.tmux.conf.local. One tmux session, one window per thing I'm working on, prefix remapped to C-a. It's the same tmux setup I run on my remote dev containers, so this works there too.
Three approaches that don't work
I tried the obvious things first. Documenting the dead ends because they all look reasonable on paper.
monitor-activity. tmux can flag a window when its pane produces output. This is exactly backwards for Claude Code. The spinner is constant output, so a window that's busy thinking looks identical to one that just finished. Every window is permanently flagged, which is the same as no window being flagged.
Renaming the window. Prepending a ✅ to the window name works right up until you have manual window names you care about (I do), or two sessions finish near each other and race on the rename. Names are shared mutable state and there's no clean way to restore the original.
The Notification hook with idle_prompt. This one is close. It fires when Claude has been waiting on you for a while, which is nearly what I want, except it fires after an idle delay. The badge shows up long after the answer did. If the point is to notice quickly, a built-in lag defeats it.
Hooks are the right primitive
Claude Code has a hooks system in ~/.claude/settings.json. Two events matter here:
Stopfires when Claude finishes a response.UserPromptSubmitfires when you send a new prompt.
Those are the exact edges of "there is an unread answer in this window." Set on Stop, clear on UserPromptSubmit. (Visiting the tab clears it too, but that part is pure tmux and comes later.)
The part that makes this work at all: hook commands inherit the session's environment, which means $TMUX_PANE is set and identifies precisely which pane, and therefore which window, the session lives in. No guessing, no PID walking, no scanning for the frontmost anything. Each session addresses its own tab.
For the badge state I use a per-window user option, @claude_status. tmux lets you hang arbitrary @-prefixed options off a window, they're scoped per window, and the theme can read them. Nothing else on the system cares about them.
The script
Here's the first version, which only knows about ✅. It grows to four states by the end, but everything structural is already here:
#!/bin/sh
# Badge the tmux window when Claude Code finishes a turn; clear it on new prompt.
[ -n "$TMUX" ] && [ -n "$TMUX_PANE" ] || exit 0
TMUX_BIN="/opt/homebrew/bin/tmux"
[ -x "$TMUX_BIN" ] || TMUX_BIN="tmux"
case "$1" in
stop)
# Skip the badge when the user is already looking at this window.
if [ "$("$TMUX_BIN" display-message -p -t "$TMUX_PANE" '#{window_active}' 2>/dev/null)" = "1" ]; then
exit 0
fi
"$TMUX_BIN" set-option -w -t "$TMUX_PANE" @claude_status done 2>/dev/null
;;
prompt)
"$TMUX_BIN" set-option -w -t "$TMUX_PANE" -u @claude_status 2>/dev/null
;;
esac
# Redraw the status line immediately (it otherwise refreshes every 10s).
for c in $("$TMUX_BIN" list-clients -F '#{client_tty}' 2>/dev/null); do
"$TMUX_BIN" refresh-client -S -t "$c" 2>/dev/null
done
exit 0Then chmod +x it.
Three details in there earn their keep:
The first line bails cleanly when there's no tmux. Hooks run for every session, including ones started in a plain terminal or over SSH. A hook that errors is noise in a place you don't want noise, so it exits 0 and does nothing.
The window_active check is what keeps the badge meaningful. If you're already watching the window when Claude finishes, you saw it happen and don't need to be told. Without this you get a badge on the window you're literally looking at, which trains you to ignore badges.
The refresh-client -S loop is the difference between "instant" and "sometimes." The tmux status line redraws on a 10 second tick by default. Setting the option doesn't force a redraw, so without this the badge shows up whenever the next tick happens to land. Pushing the redraw to every attached client makes it immediate.
Wiring it up
This is the wiring, which is what the plugin now ships as its hooks.json. Hand-written into ~/.claude/settings.json it looks like this:
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh stop" }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh prompt" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh end" }] }
]
}SessionEnd isn't needed yet at this stage, but it becomes load-bearing once the badge grows states that survive window visits. There's a section on it below.
And in the window tab format. I use the gpakosz tmux config, so this is one line in ~/.tmux.conf.local:
-tmux_conf_theme_window_status_format='#I #W'
+tmux_conf_theme_window_status_format='#I #W#{?@claude_status, ✅ ,}'#{?option,then,else} is a tmux conditional. When @claude_status is set, render the badge; otherwise render nothing. (This grows into a nested three-way conditional once there are three states. Same idea, more parentheses.)
The trailing space after the ✅ is load-bearing. The emoji is double-width, and tmux and iTerm2 disagree by one cell about how much room it takes. Without the extra space, the powerline separator on the tab overlaps the glyph and you get a clipped half-check. I lost more time to this than to the actual hook logic. If it still clips in your terminal, a single-width themed check avoids the problem:
#{?@claude_status, #[fg=#27ba09]✔#[none],}Reload with tmux source-file ~/.tmux.conf, and you can test the rendering without waiting on a real prompt:
tmux set-option -w -t :1 @claude_status done # badge appears on window 1
tmux set-option -w -t :1 -u @claude_status # and goneWhen should the badge go away?
The wiring above is mechanical. This is the first question with an actual answer to get wrong, and I got it wrong.
The hooks above give you one lifecycle: the badge appears on Stop and survives until you submit the next prompt in that window. Visiting the tab to read the answer doesn't clear it. I built that deliberately, reasoning that the tab bar should be a list of things I still owe a response to.
In practice it's wrong. Most of what those sessions produce is something I read and don't reply to. Research I asked for, a refactor I skim and accept, an answer that just tells me a thing. Under persist-until-prompt those windows keep their ✅ indefinitely, because I'm never going to type another prompt in them. Within a day the tab bar was mostly stale checkmarks, which is exactly the failure mode the window_active check was there to prevent.
Clearing on visit is better because the badge means "you haven't looked at this yet," and looking at it is the thing that makes it false. One line of tmux:
set-hook -g after-select-window 'set-option -w -u @claude_status' # brokenThat line is wrong, and it's wrong in the most annoying way available: it looks right, it installs without complaint, and it passes a test.
The hook that never fired
The symptom was that the ✅ appeared exactly when it should and then stayed forever. Clicking into the window did nothing. Only submitting a new prompt cleared it, which is the behavior I had just replaced.
after-select-window is a command hook. It fires when the literal select-window command runs. The problem is that almost nothing you actually do runs select-window. I instrumented both hooks and drove each switching method to see what really happens:
| How you switch | Command tmux runs | after-select-window |
session-window-changed |
|---|---|---|---|
| Click the tab in the status bar | switch-client -t = |
no | yes |
| Scroll wheel over the status bar | next-window / previous-window |
yes | yes |
prefix C-h / C-l |
next-window / previous-window |
yes | yes |
prefix + number |
select-window |
yes | yes |
Clicking the tab is the one path that misses, and clicking the tab is how I switch windows almost every time. So the hook was installed, correct-looking, and silently dead in the exact case that mattered.
The part worth internalizing is why my testing didn't catch it. I verified the hook by running tmux select-window -t :1 from a script and watching the badge clear. It cleared. That's a real test of the hook, and it's the one path real usage never takes. Testing a UI behavior by invoking the command I assumed the UI runs just confirms my assumption back to me. The fix was to stop assuming and go look at list-keys -T root, which says plainly that MouseDown1Status is bound to switch-client -t =.
The fix itself is a one-word change. session-window-changed is an event hook: it fires whenever the session's current window changes, no matter which command caused it. It covers all four rows.
set-hook -g session-window-changed 'set-option -w -u @claude_status'Keeping both
I still wanted the old behavior available, because "answers I owe a reply to" is genuinely the right view when I'm driving a batch of sessions rather than farming them out. So the hook reads an option instead of acting unconditionally, and a keybinding flips it:
set -g @claude_badge_clear_on_visit 1
set-hook -g session-window-changed 'if -F "#{&&:#{@claude_badge_clear_on_visit},#{==:#{@claude_status},done}}" "set-option -w -u @claude_status"'
bind B if -F '#{@claude_badge_clear_on_visit}' \
'set -g @claude_badge_clear_on_visit 0 ; display-message "Claude badge: ✅ persists until next prompt"' \
'set -g @claude_badge_clear_on_visit 1 ; display-message "Claude badge: ✅ clears when you visit the tab"'Reading the option inside the hook is what makes this live. A bare set-hook can't be switched off without re-sourcing the config, but a hook that consults an option every time it fires can. if -F evaluates a format and treats empty or 0 as false, which is why the disabled state can be a plain 0 instead of unsetting the option.
The #{&&:...} is doing the other half of the work, and it matters more once there are three states. Visiting a tab should clear done and nothing else, because running and ⏳ describe work that's still happening. Seeing them doesn't make them untrue. So the hook fires on every window change but only acts when the toggle is on and the state is exactly done.
The shell script doesn't change for any of this. UserPromptSubmit still resets the state in both modes, which is what makes the persist lifecycle work when you flip to it.
"Finished" means two different things
Once the badge was actually working I started catching a lie in it.
Claude Code can run shells in the background: a long test suite, a build, a dev server. When it does that and then ends its turn, Stop fires and the window gets a ✅. But the work isn't done. Something is still running in there, and the tab is telling me the session is ready to read. I'd click in expecting an answer and find a build at 40%.
So @claude_status stopped being a flag and became a small state machine with three values. running while a prompt is in flight, shells when the turn ended but background shells are still alive, done when there's genuinely nothing left.
tmux_conf_theme_window_status_format='#I #W#{?#{==:#{@claude_status},running}, 🔄 ,#{?#{==:#{@claude_status},shells}, ⏳ ,#{?#{==:#{@claude_status},done}, ✅ ,}}}'That's the same #{?...} conditional as before, nested three deep, with #{==:x,y} for the comparisons. Every emoji keeps its trailing space for the same clipping reason.
Finding the background shells
There's no API for this, so it's a process-tree question. At Stop time, walk from the pane down to the claude process and count its direct shell children:
bg_shell_count() {
pane_pid=$("$TMUX_BIN" display-message -p -t "$TMUX_PANE" '#{pane_pid}' 2>/dev/null)
[ -n "$pane_pid" ] || { echo 0; return; }
case "$(ps -o comm= -p "$pane_pid" 2>/dev/null)" in
*claude) claude_pid=$pane_pid ;;
*) claude_pid=$(ps -eo pid,ppid,comm | awk -v pp="$pane_pid" '$2==pp && $3 ~ /claude$/ {print $1; exit}') ;;
esac
[ -n "$claude_pid" ] || { echo 0; return; }
anc=" $$ "; p=$$; i=0
while [ "$i" -lt 10 ]; do
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' '); [ -n "$p" ] || break
anc="$anc$p "; [ "$p" = "$claude_pid" ] && break; i=$((i+1))
done
n=0
for c in $(ps -eo pid,ppid,comm | awk -v cp="$claude_pid" '$2==cp && $3 ~ /\/(zsh|bash|sh)$|^(zsh|bash|sh)$/ {print $1}'); do
case "$anc" in *" $c "*) ;; *) n=$((n+1)) ;; esac
done
echo "$n"
}Four things in there are less arbitrary than they look.
Filtering on shell names is what separates background work from infrastructure. A Claude Code session also has MCP servers hanging off it as node or python children, plus a caffeinate if something asked to keep the machine awake. Those are alive constantly and would pin every window to ⏳ forever. Background shells are zsh, so matching shell names only is the whole filter.
The ancestry walk exists because the hook is itself a shell child of claude. Without excluding it, the script counts itself, every window sits at ⏳ permanently, and the state machine never reaches ✅. It walks its own parent chain up to the claude process and skips anything it finds on the way.
ps, not pgrep. pgrep -P is the obvious tool for "children of this pid" and it silently returned nothing from inside the hook's sandboxed execution context. Not an error, just an empty result, which reads exactly like "no background shells." Parsing ps -eo pid,ppid,comm works reliably.
The ⏳ → ✅ transition needs no polling, which surprised me. When a tracked background shell exits, Claude Code wakes the session for a completion turn. That fires Stop again, the count comes back zero, and the badge resolves. I watched it live: 🔄 on submit, ⏳ seven seconds later when the turn ended with a sleep still running, then ✅ the moment the sleep exited. No timer, no watcher process.
The one gap: a raw disowned cmd & isn't tracked by Claude Code, so nothing wakes the session when it finishes, and its ⏳ waits for whatever you do next in that window.
The state that never ends
Making running and shells survive window visits is correct, since seeing that work is happening doesn't make it stop happening. But it quietly creates a state with no exit.
If a session ends while its badge is 🔄 or ⏳, that badge is permanent. Visiting won't clear it, because those states are defined not to. Stop won't clear it, because there's no session left to end a turn. The tab sits there claiming work is in flight for a process that no longer exists, and the only cure is submitting a prompt in that window or unsetting the option by hand. It's the same class of bug as the stale ✅ from earlier in the post: a badge that lies makes you stop trusting all of them.
The missing edge is SessionEnd, which fires when a session goes away:
end|unset)
unset_status
;;Wired to the SessionEnd event in the settings block above, that's the whole fix.
Testing it is where I embarrassed myself. Twice I set up a session with a live background shell, sent /exit, watched the ⏳ stay put, saw no end in my instrumented log, and concluded the hook didn't fire on that path. What actually happens is that Claude Code asks first:
The following will stop when you exit:
shell · sleep 45
❯ 1. Exit anyway 2. Move to background and exit 3. StayThe session hadn't ended, so SessionEnd was right not to fire. My test had sent /exit and never answered the question. Answer it and the hook fires and the badge clears, exactly as intended.
That's twice in one project that I got a confident wrong answer from a test that never reached the code path it claimed to exercise. The first time I drove select-window because I assumed that's what clicking a tab does. This time I sent /exit and assumed that's what exiting means. Both tests ran clean and told me nothing.
One hole remains, and it's the option in the middle of that prompt. Choose "Move to background and exit" and the shells keep running with no session left to report on them, so that window keeps its ⏳ until you use it again. I've left it, because at that point the badge is arguably telling the truth: something really is still running in there.
The fourth state: blocked on you
Running the three-state version for a day surfaced the next lie. A window waiting on a permission prompt, or on one of Claude's multiple-choice questions, showed 🔄, indistinguishable from one that was actually working. The tab said "busy" when the truth was "busy waiting for you."
There's no documented event for this, so I went looking in the binary. Notification carries a notification_type, and the full value set is permission_prompt, idle_prompt, auth_success, elicitation_dialog, elicitation_complete, elicitation_response, agent_needs_input, agent_completed.
Three of those mean blocked on the user: permission_prompt, elicitation_dialog, agent_needs_input. idle_prompt deliberately isn't one of them, because it fires when Claude is merely idle, so wiring it would put ❓ on every window that had simply finished, which is what ✅ already means.
AskUserQuestion turned out to be a real tool name, which is better than a notification: PreToolUse with that matcher fires the instant the picker opens, with no delay and no inference.
Retiring the state is the inelegant part. There's no "the user answered" event. What is observable is that answering a prompt or a question is always followed by a tool completing, so PostToolUse clears it. That fires on every single tool call, so the script bails on its first tmux read unless a question is actually pending.
The bug that only showed up on the tab I was looking at
Then the badges started vanishing exactly when I clicked into a tab, and coming back when I clicked away.
gpakosz renders the selected tab from tmux_conf_theme_window_status_current_format, a completely different variable from tmux_conf_theme_window_status_format. I had only ever changed the second one. Inactive tabs had badges. The active tab rendered nothing at all.
What makes this worth writing down is that my testing had "confirmed" it worked. I was asserting on the value of the @claude_status option and on the inactive format string, and both were correct the entire time the tab in front of me was blank. The test measured the state, not the pixels.
The same fix pass killed a second assumption. Stop had been erasing the badge whenever the window was active, on the theory that if you're looking at the window you saw it finish. You didn't. You were reading something else, it finished, and now there's nothing to draw you back. Being parked in a window isn't the same as having read what landed in it.
That's why ✅ now clears on two edges instead of one: when you arrive at a window, and when you leave one it appeared in. The second is the honest moment. It's the point at which you've genuinely had your chance.
Making it something other people can install
At this point it was four files spread across my dotfiles with an absolute home path hardcoded into every hook command. Useless to anyone else.
Splitting it in two fixed that. bin/claude-status sets a tmux option and knows nothing about rendering. claude-status.tmux reads that option and publishes a format fragment as @claude_badge_fmt. They're joined by one string and nothing else, so either can be replaced.
Publishing a fragment instead of owning the status line is the part that makes it composable. It's also the only thing that can work under gpakosz, which rebuilds the status formats after sourcing your config and would overwrite anything set directly.
The hooks became a Claude Code plugin, which was the piece I expected to be hardest and turned out to be cleanest. Plugins can ship a hooks.json, and ${CLAUDE_PLUGIN_ROOT} expands inside hook commands, which is exactly what killed the hardcoded paths.
One thing I couldn't find documented anywhere and had to prove: plugin hooks.json honours the matcher field. The ❓ state depends entirely on it. I verified it by making the plugin the only source of hooks, with settings.json carrying none, and driving a real question dialog.
Then I broke it for myself. Deleting the old script while sessions were still running meant every one of those sessions kept invoking a path that no longer existed, and a missing hook script fails silently. Badges froze mid-state. Two of my tabs sat on a ❓ that never resolved after I answered. A symlink at the old path fixed it without restarting anything, and that migration note is now in the README.
Two things that will confuse you
Hooks are snapshotted when a session starts. Add all of this, watch nothing happen in your existing sessions, and start debugging the script. The script is fine. Long-running sessions captured their hook config at launch and won't pick up changes.
Restarting the claude process is the only fix, and I say that having tested the alternatives rather than assuming. /hooks does not reload. In 2.1.x it's a read-only viewer that says so on screen, and it only lists settings.json hooks, so plugin-provided ones don't appear in it at all. Installing or enabling a plugin doesn't propagate into live sessions either. I verified both by disabling the plugin, starting a session so it snapshotted without the hooks, re-enabling, running /hooks, and watching the badge stay dead. A fresh session worked immediately. The tmux window doesn't need touching, only the process, and claude --resume restarts it while keeping the thread.
prefix + r suddenly fails with returned 127. Unrelated to any of this, but it will bite you eventually and it looks like your new config broke something. The gpakosz framework stores a TMUX_PROGRAM environment variable pointing at a versioned Homebrew Cellar path, like .../Cellar/tmux/3.6b/bin/tmux. Upgrade tmux and that path stops existing, so every config reload fails silently until you notice. Fix:
tmux set-environment -g TMUX_PROGRAM /opt/homebrew/bin/tmux
tmux source-file ~/.tmux.confThis breaks on every Homebrew tmux upgrade, so I added it as a check to the doctor.sh in my machine setup repo rather than rediscovering it twice a year.
Three adjacent fixes
While I was in here I chased down some separate annoyances that had been going on for months.
The dinging. The gpakosz config ships monitor-activity on with visual-activity off, which means tmux turns "activity in another window" into an audible bell. That's a reasonable default until every window contains a Claude spinner animating constantly, at which point it's a machine that dings forever, worst right after switching tabs because the alert re-arms for the window you just left. I'd half-assumed this was an iTerm setting and never chased it. The badges do everything activity monitoring was doing here, so it can go entirely:
set -g monitor-activity off
set -g bell-action noneThe frozen window. Occasionally one iTerm window's tmux would look completely dead. No response to tab clicks, no response to the prefix. Opening a new iTerm window and attaching to the same session worked fine, which made it look like a client-specific tmux bug.
It isn't. The frozen client was still sending input the whole time. tmux was logging its clicks. Only its output was frozen, which is a very different problem and has two causes worth immunizing against:
Ctrl-S. XON/XOFF flow control, which freezes terminal output until you press Ctrl-Q. It's one key away from Ctrl-A if your prefix is remapped there, and nothing on screen tells you what happened. Turn it off in ~/.zshrc, before the line that sources oh-my-zsh:
# Disable XON/XOFF flow control: an accidental Ctrl-S freezes the terminal's
# output (looks like a dead tmux client; Ctrl-Q unfreezes).
[[ -t 0 ]] && stty -ixonOrdering matters. If oh-my-zsh is configured to autostart tmux, it execs straight into it and never reaches anything below that line.
prefix + Ctrl-z, which is suspend-client. One key off the C-a C-a pane-cycling chord I use constantly. When your shell exec'd into tmux there's no visible job control to resume from, so it presents as a dead terminal:
unbind C-zIf a window still freezes on you, try Ctrl-Q in it first. Otherwise, from a healthy window, tmux list-clients shows the stuck tty and tmux detach-client -t /dev/ttysNNN frees it.
Every fix in this section is one line, and between them they were costing me a restarted terminal every couple of weeks and an ambient stream of dings I'd stopped noticing I was annoyed by. None of them had anything to do with badges. I only found them because building the badges made me read config I'd been carrying unexamined for years.
Small thing overall. It took the polling loop out of my day, and it's now a plugin you can install rather than a config file you'd have to copy.
Three separate bugs in this project passed a green test: a tmux hook that never fired for real tab clicks, a format string that rendered on every tab except the one I was looking at, and a hook I'd deleted out from under my own running sessions. Each one had a test that exercised something adjacent to the thing that mattered. The test suite in the repo now asserts on what a tab actually renders, active and inactive, against a clean tmux, because that's the only version of the question that was ever real.