cd /news/ai-agents/teaching-tmux-to-babysit-my-claude-c… · home topics ai-agents article
[ARTICLE · art-17962] src=blog.angeloff.name pub= topic=ai-agents verified=true sentiment=· neutral

Teaching tmux to babysit my Claude Code agents

A developer built a tmux-based notification system that displays colored dots in window tabs to indicate when Claude Code agents need attention. The system uses Claude Code hooks to write status flags to tmux window variables, showing amber for blocked agents needing permission or input, green for completed tasks, and no dot for agents still working. The solution allows developers running multiple Claude Code sessions across tmux windows to glance at their terminal and immediately identify which agents require human intervention.

read9 min publishedMay 29, 2026

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.

── more in #ai-agents 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/teaching-tmux-to-bab…] indexed:0 read:9min 2026-05-29 ·