Teaching tmux to babysit my Claude Code agents

StanAngeloff1 pts0 comments

Teaching tmux to babysit my Claude Code agents · tmpfs /home/stan If you are like me, you no longer run one Claude Code session — you run a small fleet. One window is building a feature in a TypeScript monorepo, another is reviewing a colleague’s pull request, a third is chasing a redraw bug in a Neovim plugin. tmux makes this easy: a window per agent, Alt + 1–9 to jump between them. Often they are not even different projects — just git worktrees of the same repo, one agent per branch.

✦Dictated, not typed — but read. I thought this post out loud and transcribed it, then wrote it up with Claude Code, which had direct access to the code and config it describes. I read every word; the ideas and direction are mine, the prose a collaboration.

The trouble starts at three or four agents. You burn time cycling through windows playing twenty questions with yourself: is this one still working? Has that one stopped to ask me something? Did the one from ten minutes ago finish, or is it waiting on me to approve a git push? An agent is autonomous right up until the moment it needs you — and out of the box it has no way to tap you on the shoulder.

Hacker News is full of the fix: a new wave of agent-runner tools, many with a column of vertical tabs, one per agent, each lighting up when it wants attention — Cursor’s v3 rewrite, an “IDE for the agents era”, “sandboxed coding agents for a team” and Google’s Antigravity, now on its second version. These do far more than light up a tab — PR workflows, GitHub integration, sandboxing, team orchestration — and I will probably borrow ideas from them. But I live in tmux all day, and all I wanted was the small part: a glanceable sense of which agent needs me. That does not need a new IDE, just the windows I already have open.

So I taught tmux to show it. Every window now carries a coloured dot for the Claude Code session inside it:

amber — blocked, needs me (a permission prompt or a question);

green — finished, with a response waiting to be read;

nothing — working away, or nothing to report.

default<br>1│ ● payments<br>2│ flemma<br>3│ blog

My tmux status bar: window 1 (payments) is blocked and waiting on me — the amber dot — while window 3, blog, is the one I am looking at, so it carries no dot.

The green dot clears itself the instant I switch to that window — by the time I am reading, it is gone. The amber dot is stubborner: it stays until I have actually dealt with the request, because glancing at a window is not the same as answering it.

It is two halves that never touch directly — they meet on a single tmux variable. Claude Code hooks write it; a tmux format string reads it back and paints the dot. I drive it through home-manager in my nix-meridian config, so the snippets are Nix, but the substance is the shell and tmux config — lift those straight out.

Half one: Claude Code flags the window

Claude Code hooks run a shell command at set points in a session. The ones I use:

PermissionRequest — wants to do something unauthorised, waiting on a yes/no.

Elicitation — asking you a structured question.

Stop / StopFailure — the turn ended.

PostToolUse — a tool just finished.

UserPromptSubmit — you sent a new prompt.

SessionEnd — Claude exited.

The glue is one variable: $TMUX_PANE. tmux sets it in every pane and Claude Code inherits it, so a hook always knows which window it is running in. Flagging that window is one command:

# Flag this pane's window as needing attention:<br>$ tmux set -w -t "$TMUX_PANE" @claude-state permission<br># ...and clear it:<br>$ tmux set -wu -t "$TMUX_PANE" @claude-state<br>@claude-state is a custom option — tmux lets you invent any name starting with @. -w scopes it to the window and -u unsets it. One string per window, holding permission, elicitation, idle or nothing.

I wrap set, clear and a conditional clear into a small Nix helper:

tmux-claude-state =<br>let<br>tmux = "${lib.getBin pkgs.tmux}/bin/tmux";<br>in<br># Set @claude-state to a given value on this pane's window.<br>set = state:<br>''[ -n "$TMUX_PANE" ] && ${tmux} set -w -t "$TMUX_PANE" @claude-state ${state} || true'';

# Clear it, whatever it was.<br>reset =<br>''[ -n "$TMUX_PANE" ] && ${tmux} set -wu -t "$TMUX_PANE" @claude-state || true'';

# Clear it *only* if we are currently blocked — don't clobber anything else.<br>reset-blocked = ''<br>[ -n "$TMUX_PANE" ] && case "$(${tmux} show -wv -t "$TMUX_PANE" @claude-state 2>/dev/null)" in<br>permission|elicitation) ${tmux} set -wu -t "$TMUX_PANE" @claude-state ;;<br>esac; true'';<br>};<br>The [ -n "$TMUX_PANE" ] guard makes it a no-op outside tmux; the trailing || true stops a failed tmux call surfacing as a hook error. Then wire them up (trimmed — the full set is in the repo):

hooks = {<br>PermissionRequest = [{<br>hooks = [<br>{ type = "command"; command = "...play a chime..."; } # more on this later<br>{ type = "command"; command = tmux-claude-state.set "permission"; }<br>];<br>}];

Stop = [{ hooks = [{ type = "command"; command = tmux-claude-state.set "idle"; }]; }];

PostToolUse = [{ hooks = [{ type =...

tmux claude window state tmux_pane code

Related Articles