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.
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.
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:
$ tmux set -w -t "$TMUX_PANE" @claude-state permission
$ tmux set -wu -t "$TMUX_PANE" @claude-state
@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 =
let
tmux = "${lib.getBin pkgs.tmux}/bin/tmux";
in
{
set = state:
''[ -n "$TMUX_PANE" ] && ${tmux} set -w -t "$TMUX_PANE" @claude-state ${state} || true'';
reset =
''[ -n "$TMUX_PANE" ] && ${tmux} set -wu -t "$TMUX_PANE" @claude-state || true'';
reset-blocked = ''
[ -n "$TMUX_PANE" ] && case "$(${tmux} show -wv -t "$TMUX_PANE" @claude-state 2>/dev/null)" in
permission|elicitation) ${tmux} set -wu -t "$TMUX_PANE" @claude-state ;;
esac; true'';
};
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 = {
PermissionRequest = [{
hooks = [
{ type = "command"; command = "...play a chime..."; } # more on this later
{ type = "command"; command = tmux-claude-state.set "permission"; }
];
}];
Stop = [{ hooks = [{ type = "command"; command = tmux-claude-state.set "idle"; }]; }];
PostToolUse = [{ hooks = [{ type = "command"; command = tmux-claude-state.reset-blocked; }]; }];
UserPromptSubmit = [{ hooks = [{ type = "command"; command = tmux-claude-state.reset; }]; }];
};
Elicitation
mirrors PermissionRequest
(without the chime 1),
StopFailure
mirrors Stop
, SessionEnd
mirrors UserPromptSubmit
. The logic:blocked(PermissionRequest
/Elicitation
) →permission
/elicitation
— amber.finished(Stop
/StopFailure
) →idle
— green.tool ran(PostToolUse
) → clear it if we were amber; you have just approved it.new prompt/** session end**→ clear everything.
Nothing flags “working” — that is the default, and a row of nine busy dots would just be noise.
Half two: tmux paints the dot
tmux renders the variable in window-status-format
, the template for every inactive window:
setw -g window-status-format \
" #I│ #{?#{@claude-state},#{?#{==:#{@claude-state},idle},#[fg=colour34]●#[fg=colour247] ,#[fg=colour214]●#[fg=colour247] },}#W "
tmux’s format syntax is dense: #{?cond,then,else}
is a ternary, and they nest. Unfolded:
#{?#{@claude-state}, # is @claude-state set at all?
#{?#{==:#{@claude-state},idle}, # yes — is it exactly "idle"?
#[fg=colour34]●#[fg=colour247] , # yes → green dot, then back to grey
#[fg=colour214]●#[fg=colour247] }, # no → amber dot, then back to grey
} # not set → render nothing
No @claude-state
and you get an empty string, so the name renders as normal. Otherwise an exact match on idle
picks the colour. ●
is a Unicode circle; #[fg=...]
sets the colour on either side of it. The indices are the plain 256-colour palette: colour34
green (“done, come and look”), colour214
amber (“I need you”) and colour247
the grey for inactive text, restored after the dot. A traffic light, hiding in a status bar.
Clearing the green dot on focus
A green dot is only useful until you look, so I clear it when you select the window:
set-hook -g after-select-window \
'if -F "#{==:#{@claude-state},idle}" "set -wu @claude-state"'
It only clears idle
, never permission
/ elicitation
. Green means “something to read”, and switching there reads it; amber means “something to do”, which a glance does not. The amber dot survives until the work happens (PostToolUse
→ reset-blocked
) or you send a new prompt.
One last nicety: the focused tab uses a separate window-status-current-format
with no dot logic at all — if you are looking at a window, you do not need telling what is in it. So dots only ever appear on the windows you are not in.
Start to finish
- I send a prompt.
UserPromptSubmit
clears the flag — no dot. - Claude works. Nothing touches the state — still no dot.
- It hits a
git push
it cannot run unattended.PermissionRequest
fires: chime, amber tab. - I approve it — the amber dot stayed, since glancing didn’t clear it. The command runs and
PostToolUse
clears the flag. - The turn ends.
Stop
turns the tab green. - I switch over to read it.
after-select-window
clears the green dot before I finish the first line.
Ring a bell
Dots work when your eyes are on the terminal. They do not when you have wandered off. So the blocking case — and only that — also makes a noise:
PermissionRequest = [{
hooks = [
{
type = "command";
command = "${lib.getBin pkgs.pipewire}/bin/pw-play ${./audio/notifications/mixkit-clear-announce-tones-2861.mp3}";
timeout = 5;
}
{ type = "command"; command = tmux-claude-state.set "permission"; }
];
}];
pw-play
is PipeWire’s player; the clip is a short Mixkit tone vendored into the repo, so nothing reaches the network just to make a sound. Use whatever you like.
It rings on PermissionRequest
only, never on Stop
. A chime on every finished turn would be nine an hour and muted by lunch; a chime only when an agent is genuinely stuck is worth keeping on. This is what lets me walk away — start three agents, go and make a coffee, come back when one of them calls. The rest carry on.
Two rough edges
It is not perfect:
Forked subagents sometimes flip a window amber for nothing. With subagent forking on, spawning one occasionally turns the window amber when nothing is waiting. Something inside the fork trips a blocking hook; I have not pinned down what yet.
The amber dot clears a beat late. I clear it on PostToolUse
— when the tool finishes, not when you approve it. Green-light a long build or a sleep 30
and the window stays amber for the whole run, though it stopped needing you the second you said yes. A PreToolUse
hook would clear it sooner; that one is on the list.
Wrapping up
That is the whole thing: the see-every-agent, ping-me-when-one-needs-you experience the big tools are built around, except this slice of it lives in the terminal I already work in. A dozen lines of hooks, one format string and a sound file.
I am not the only one who wants this — Solo is a lovely native workspace (Tauri, not Electron) that runs all your agents with status indicators built in, and it is well worth a look. It does far more than my handful of dots; it is just more than I need. If you would rather have the whole workspace handed to you, that is what it is for.
None of the clever part is Claude-specific — it is hooks writing a variable and a status bar reading it. Point tmux set -w @claude-state
at a long make
or a deploy and you get the same dots for free.
The full version lives in my nix-meridian config under home/apps/tmux
and home/apps/claude-code
. Steal it, and never go window-hunting again.
Happy hacking!
Footnotes #
Both blocking states — a permission request and an elicitation — show the same amber dot, but only
PermissionRequest
rings the bell; that is the one that tends to catch me mid-coffee.↩