{"slug": "teaching-tmux-to-babysit-my-claude-code-agents", "title": "Teaching tmux to babysit my Claude Code agents", "summary": "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.", "body_md": "# Teaching tmux to babysit my Claude Code agents\n\nIf 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.\n\nThe 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`\n\n? 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.\n\nHacker 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.\n\nSo I taught tmux to show it. Every window now carries a coloured dot for the Claude Code session inside it:\n\n**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.\n\nThe 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.\n\nIt 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.\n\n### Half one: Claude Code flags the window\n\nClaude Code [hooks](https://code.claude.com/docs/en/hooks) run a shell command at set points in a session. The ones I use:\n\n`PermissionRequest`\n\n— wants to do something unauthorised, waiting on a yes/no.`Elicitation`\n\n— asking you a structured question.`Stop`\n\n/`StopFailure`\n\n— the turn ended.`PostToolUse`\n\n— a tool just finished.`UserPromptSubmit`\n\n— you sent a new prompt.`SessionEnd`\n\n— Claude exited.\n\nThe glue is one variable: `$TMUX_PANE`\n\n. 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:\n\n``` bash\n# Flag this pane's window as needing attention:\n$ tmux set -w -t \"$TMUX_PANE\" @claude-state permission\n# ...and clear it:\n$ tmux set -wu -t \"$TMUX_PANE\" @claude-state\n```\n\n`@claude-state`\n\nis a custom option — tmux lets you invent any name starting with `@`\n\n. `-w`\n\nscopes it to the window and `-u`\n\nunsets it. One string per window, holding `permission`\n\n, `elicitation`\n\n, `idle`\n\nor nothing.\n\nI wrap set, clear and a conditional clear into a small Nix helper:\n\n``` js\ntmux-claude-state =\n  let\n    tmux = \"${lib.getBin pkgs.tmux}/bin/tmux\";\n  in\n  {\n    # Set @claude-state to a given value on this pane's window.\n    set = state:\n      ''[ -n \"$TMUX_PANE\" ] && ${tmux} set -w -t \"$TMUX_PANE\" @claude-state ${state} || true'';\n\n    # Clear it, whatever it was.\n    reset =\n      ''[ -n \"$TMUX_PANE\" ] && ${tmux} set -wu -t \"$TMUX_PANE\" @claude-state || true'';\n\n    # Clear it *only* if we are currently blocked — don't clobber anything else.\n    reset-blocked = ''\n      [ -n \"$TMUX_PANE\" ] && case \"$(${tmux} show -wv -t \"$TMUX_PANE\" @claude-state 2>/dev/null)\" in\n        permission|elicitation) ${tmux} set -wu -t \"$TMUX_PANE\" @claude-state ;;\n      esac; true'';\n  };\n```\n\nThe `[ -n \"$TMUX_PANE\" ]`\n\nguard makes it a no-op outside tmux; the trailing `|| true`\n\nstops a failed `tmux`\n\ncall surfacing as a hook error. Then wire them up (trimmed — the full set is in the repo):\n\n```\nhooks = {\n  PermissionRequest = [{\n    hooks = [\n      { type = \"command\"; command = \"...play a chime...\"; }   # more on this later\n      { type = \"command\"; command = tmux-claude-state.set \"permission\"; }\n    ];\n  }];\n\n  Stop = [{ hooks = [{ type = \"command\"; command = tmux-claude-state.set \"idle\"; }]; }];\n\n  PostToolUse = [{ hooks = [{ type = \"command\"; command = tmux-claude-state.reset-blocked; }]; }];\n\n  UserPromptSubmit = [{ hooks = [{ type = \"command\"; command = tmux-claude-state.reset; }]; }];\n};\n```\n\n`Elicitation`\n\nmirrors `PermissionRequest`\n\n(without the chime 1),\n\n`StopFailure`\n\nmirrors `Stop`\n\n, `SessionEnd`\n\nmirrors `UserPromptSubmit`\n\n. The logic:**blocked**(`PermissionRequest`\n\n/`Elicitation`\n\n) →`permission`\n\n/`elicitation`\n\n— amber.**finished**(`Stop`\n\n/`StopFailure`\n\n) →`idle`\n\n— green.**tool ran**(`PostToolUse`\n\n) → clear it if we were amber; you have just approved it.**new prompt**/** session end**→ clear everything.\n\nNothing flags “working” — that is the default, and a row of nine busy dots would just be noise.\n\n### Half two: tmux paints the dot\n\ntmux renders the variable in `window-status-format`\n\n, the template for every inactive window:\n\n```\nsetw -g window-status-format \\\n  \" #I│ #{?#{@claude-state},#{?#{==:#{@claude-state},idle},#[fg=colour34]●#[fg=colour247] ,#[fg=colour214]●#[fg=colour247] },}#W \"\n```\n\ntmux’s [format syntax](https://man.openbsd.org/tmux.1#FORMATS) is dense: `#{?cond,then,else}`\n\nis a ternary, and they nest. Unfolded:\n\n```\n#{?#{@claude-state},                    # is @claude-state set at all?\n  #{?#{==:#{@claude-state},idle},       #   yes — is it exactly \"idle\"?\n    #[fg=colour34]●#[fg=colour247] ,    #     yes → green dot, then back to grey\n    #[fg=colour214]●#[fg=colour247] },  #     no  → amber dot, then back to grey\n}                                        # not set → render nothing\n```\n\nNo `@claude-state`\n\nand you get an empty string, so the name renders as normal. Otherwise an exact match on `idle`\n\npicks the colour. `●`\n\nis a Unicode circle; `#[fg=...]`\n\nsets the colour on either side of it. The indices are the plain 256-colour palette: `colour34`\n\ngreen (“done, come and look”), `colour214`\n\namber (“I need you”) and `colour247`\n\nthe grey for inactive text, restored after the dot. A traffic light, hiding in a status bar.\n\n### Clearing the green dot on focus\n\nA green dot is only useful until you look, so I clear it when you select the window:\n\n```\n# When you switch to a window, if its Claude state is the green \"idle\"\n# flag, clear it — you are about to read the response anyway.\nset-hook -g after-select-window \\\n  'if -F \"#{==:#{@claude-state},idle}\" \"set -wu @claude-state\"'\n```\n\nIt only clears `idle`\n\n, never `permission`\n\n/ `elicitation`\n\n. 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`\n\n→ `reset-blocked`\n\n) or you send a new prompt.\n\nOne last nicety: the focused tab uses a separate `window-status-current-format`\n\nwith 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.\n\n### Start to finish\n\n- I send a prompt.\n`UserPromptSubmit`\n\nclears the flag — no dot. - Claude works. Nothing touches the state — still no dot.\n- It hits a\n`git push`\n\nit cannot run unattended.`PermissionRequest`\n\nfires: chime, amber tab. - I approve it — the amber dot stayed, since glancing didn’t clear it. The command runs and\n`PostToolUse`\n\nclears the flag. - The turn ends.\n`Stop`\n\nturns the tab green. - I switch over to read it.\n`after-select-window`\n\nclears the green dot before I finish the first line.\n\n### Ring a bell\n\nDots 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:\n\n```\nPermissionRequest = [{\n  hooks = [\n    {\n      type = \"command\";\n      command = \"${lib.getBin pkgs.pipewire}/bin/pw-play ${./audio/notifications/mixkit-clear-announce-tones-2861.mp3}\";\n      timeout = 5;\n    }\n    { type = \"command\"; command = tmux-claude-state.set \"permission\"; }\n  ];\n}];\n```\n\n`pw-play`\n\nis 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.\n\nIt rings on `PermissionRequest`\n\nonly, never on `Stop`\n\n. 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.\n\n### Two rough edges\n\nIt is not perfect:\n\n**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.\n\n**The amber dot clears a beat late.** I clear it on `PostToolUse`\n\n— when the tool *finishes*, not when you approve it. Green-light a long build or a `sleep 30`\n\nand the window stays amber for the whole run, though it stopped needing you the second you said yes. A `PreToolUse`\n\nhook would clear it sooner; that one is on the list.\n\n### Wrapping up\n\nThat 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.\n\nI 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.\n\nNone 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`\n\nat a long `make`\n\nor a deploy and you get the same dots for free.\n\nThe full version lives in my [nix-meridian](https://github.com/StanAngeloff/nix-meridian) config under `home/apps/tmux`\n\nand `home/apps/claude-code`\n\n. Steal it, and never go window-hunting again.\n\nHappy hacking!\n\n## Footnotes\n\n-\nBoth blocking states — a permission request and an elicitation — show the same amber dot, but only\n\n`PermissionRequest`\n\nrings the bell; that is the one that tends to catch me mid-coffee.[↩](#user-content-fnref-1)", "url": "https://wpnews.pro/news/teaching-tmux-to-babysit-my-claude-code-agents", "canonical_source": "https://blog.angeloff.name/post/2026/05/29/teaching-tmux-to-babysit-my-claude-code-agents/", "published_at": "2026-05-29 18:09:53+00:00", "updated_at": "2026-05-29 18:16:55.624731+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-products"], "entities": ["Claude Code", "tmux", "Cursor", "Google", "Antigravity", "Neovim", "TypeScript", "Hacker News"], "alternates": {"html": "https://wpnews.pro/news/teaching-tmux-to-babysit-my-claude-code-agents", "markdown": "https://wpnews.pro/news/teaching-tmux-to-babysit-my-claude-code-agents.md", "text": "https://wpnews.pro/news/teaching-tmux-to-babysit-my-claude-code-agents.txt", "jsonld": "https://wpnews.pro/news/teaching-tmux-to-babysit-my-claude-code-agents.jsonld"}}