# Teaching tmux to babysit my Claude Code agents

> Source: <https://blog.angeloff.name/post/2026/05/29/teaching-tmux-to-babysit-my-claude-code-agents/>
> Published: 2026-05-29 18:09:53+00:00

# Teaching tmux to babysit my Claude Code agents

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](https://news.ycombinator.com/item?id=47618084), an [“IDE for the agents era”](https://news.ycombinator.com/item?id=48236770), [“sandboxed coding agents for a team”](https://news.ycombinator.com/item?id=48225040) and [Google’s Antigravity](https://news.ycombinator.com/item?id=48234090), 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](https://github.com/StanAngeloff/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](https://code.claude.com/docs/en/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:

``` bash
# Flag this pane's window as needing attention:
$ tmux set -w -t "$TMUX_PANE" @claude-state permission
# ...and clear it:
$ 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:

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

    # Clear it, whatever it was.
    reset =
      ''[ -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.
    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](https://man.openbsd.org/tmux.1#FORMATS) 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:

```
# When you switch to a window, if its Claude state is the green "idle"
# flag, clear it — you are about to read the response anyway.
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](https://mixkit.co/free-sound-effects/) 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](https://soloterm.com/) 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](https://github.com/StanAngeloff/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.[↩](#user-content-fnref-1)
