Multi-instance Claude Code status indicator for tmux (+ persistent macOS alerts & jump-to switcher)
A tmux status-bar indicator that summarises every live Claude Code CLI instance at a glance plus macOS notifications that stay on screen until dismissed (and retract themselves once you look at the instance they came from), and an fzf popup to jump straight to any instances pane.
Pass the following handoff document to your own coding agent to start
implementing a version of this workflow tailored to you and your work
environment. The handoff document captures all of the important parts of
how I implemented the workflow and why:
# Multi-instance Claude Code status indicator for tmux (+ persistent macOS alerts & jump-to switcher)
A tmux status-bar indicator that summarises **every live Claude Code CLI instance at a glance** plus macOS
notifications that **stay on screen until dismissed** (and retract themselves once you look at the
instance they came from), and an fzf popup to jump straight to any instance's pane. Built and
verified on **macOS 15.6 + Ghostty + tmux 3.6a + stock bash 3.2 + Claude Code CLI**; the core is
portable, the notification module is macOS-specific and called out as such.
---
## Quick reference
**The status bar** (`status-right`), e.g. `●2 👁3 ✋games ✅notes`:
| Element | Meaning |
| ----------------- | -------------------------------------------------- |
| `●N` (blue) | N instances **actively processing right now** |
| `👁M` (grey) | M instances **idle / already seen** |
| `✋name` (orange) | instance **blocked waiting for your approval** |
| `✅name` (green) | instance that **just finished**, not yet looked at |
| `· claude idle` | nothing running |
Counters lead so they survive truncation. `name` is the **tmux session name**. `✅` collapses into
`👁` once you focus that pane. More than 4 finished at once collapse to `✅+K`.
**Keys and commands**
| Action | How |
| -------------------------------- | --------------------------------------------------------- |
| Jump to any instance | `prefix + a` → fzf popup (live pane preview) → `Enter` |
| Dismiss one alert | Look at that instance's pane — it retracts itself |
| Dismiss all alerts for a session | Send it new work (`running` retracts), or end the session |
| Force a status-bar redraw | `tmux refresh-client -S` |
| Rebuild the notifier app | `~/.claude/hooks/claude-notifier-install.sh` |
**The day-to-day loop:** fire off work in several panes → tab away to a browser/editor → an alert
sits on screen per finished-or-blocked instance until you deal with it → open your terminal, look
at that pane, its alert disappears and its `✅` folds into `👁` → `prefix + a` to hop to the next.
**Where things live**
| Path | What |
| -------------------------------- | ------------------------------------------- |
| `~/.claude/hooks/*.sh` | all scripts |
| `~/.claude/status/<session-id>` | state file, `state\|epoch\|pane\|cwd` |
| `~/.claude/status/.viewed-<id>` | "you've looked at this" marker |
| `~/.claude/status/.notify-<id>` | "an alert is currently posted" marker |
| `~/.claude/bin/ClaudeNotify.app` | private notifier bundle (Module 7) |
| `~/.claude/settings.json` | hook wiring |
| `~/.tmux.conf` | status-right, focus hooks, switcher binding |
---
## What this is and why
People increasingly run **several Claude Code CLI sessions at once** — one per project, worktree,
or tmux pane. You lose track of them. Which are actively working? Which are blocked waiting for
you to approve a command? Which finished five minutes ago and are just sitting there? You end up
cycling through panes to check, and an agent that finished while you were in a browser can idle
for a long time before you notice.
This solves that with four things:
1. A **tmux status-bar indicator** summarising every live instance at a glance.
2. **macOS alerts** when an instance finishes or needs approval _while you're not looking at the
terminal_ — deliberately **manual-dismiss**, so they're still there when you come back.
3. **Automatic retraction** of an alert once you've actually seen that instance, so persistence
never turns into a pile of stale alerts to clear by hand.
4. A **popup switcher** to jump straight to any instance's pane.
The design point worth keeping: the status bar answers _"what's the state of everything?"_ at a
glance, and the alerts answer _"do I need to come back to the terminal at all?"_ when you're
elsewhere.
---
## For the implementing agent (read this first)
> **Default: build this as specified — then check in about variations.**
> The reader most likely saw this demonstrated and wants the same thing. Reproduce it as
> described. The defaults encode decisions arrived at by hitting the problems in _Known gotchas_ —
> treat them as load-bearing.
>
> **Do not open with "so which parts would you like?"** — that dumps design work on someone who
> just wants it working. Instead:
>
> 1. **State the plan and confirm it** — one question, phrased so "yes, all of it" is the easy
> answer. For example, verbatim: _"I'll install the full setup: the tmux status indicator for
> all your Claude instances, persistent macOS alerts that auto-dismiss once you view the
> instance, and a `prefix + a` popup switcher. Eight modules, roughly 15 minutes, and I'll
> verify each one as I go. Sound good?"_
> 2. **Interview only where their environment forces a decision** — not macOS (Module 6/7 are
> macOS-only as written), not tmux, a different terminal app (`TERMINAL_APP` must match their
> terminal's process name), `prefix + a` already bound, an existing `~/.claude/settings.json`
> with a `hooks` key to merge into, no `jq`/`fzf`. These are _compatibility_ questions, not
> preference questions. Don't silently substitute — ask, then adapt.
> 3. **Call out the genuinely optional pieces**, so opting out is cheap and informed: Module 5
> (viewed-collapse), Module 6+7 (notifications), Module 8 (switcher). Modules 1–4 are the core
> and only make sense together.
> 4. **Then invite variations** — and build them if asked. Don't propose a redesign unprompted.
>
> Deviate when the user asks or their environment requires it. Not because something looks
> tidier — that's how the gotchas get reintroduced.
The modules below are independently installable, so a user who wants only the status bar, or only
the notifications, can have exactly that — but that's their call to make, not your opening move.
---
## Known gotchas
**Load-bearing — don't simplify these away.** Each cost real debugging time.
| # | Symptom | Cause | Fix | Module |
| --- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| 1 | `●` never lights up | "Working" is detected by grepping the pane's footer for `esc to interrupt`, a Claude TUI string, not a public API | Update `WORKING_MARK` at the top of `claude-tmux-status.sh` **and** `claude-switcher.sh` | 3, 8 |
| 2 | `✋`/`✅` never appear for a session | Hook config only applies to **newly started** Claude sessions | Restart the session. Discovery still lists old ones as `👁` — that's intended | 1 |
| 3 | Scripts fail with `declare -A` / substitution errors | macOS ships **bash 3.2** | Scripts avoid associative arrays and modern syntax on purpose; keep that if you edit them | all |
| 4 | Notifier call hangs the hook | `terminal-notifier`'s `-sender` / `-activate` flags can hang | Use only `-title/-message/-group` (and `-remove`) as shown | 6, 7 |
| 5 | Everything looks correctly configured but **no notification ever pops** | macOS suppresses **all** notifications — banners _and_ alerts — while the display is **mirrored or shared**, silently routing them to Notification Center. Costs hours if you record screencasts | System Settings → Notifications → **"Allow notifications when mirroring or sharing the display" = ON**. Diagnose with `system_profiler SPDisplaysDataType \| grep -i mirror` | 6, 7 |
| 6 | Editing Homebrew `terminal-notifier.app`'s `Info.plist` to `alert` changes nothing | macOS seeds an app's alert style from `NSUserNotificationAlertStyle` **only on that bundle id's first registration**. An already-registered app ignores it forever | Use a **copy with its own unused `CFBundleIdentifier`** (Module 7). This is the whole reason that module exists — not cosmetics | 7 |
| 7 | `-remove` silently does nothing | `NSUserNotificationCenter` only manages **its own app's** delivered notifications | The retract must run through the **same bundle that posted** — `ClaudeNotify.app`'s binary, not the Homebrew `terminal-notifier` on `PATH` | 7 |
| 8 | Notifier process spawns constantly / hooks feel sluggish | `running` fires on **every tool call**; an ungated retract launches a GUI binary each time | Gate every retract on `.notify-<id>` existing — it doubles as "an alert is currently posted" | 7 |
| 9 | Alerts pile up unread even after you've seen them | Manual-dismiss is the point, but nothing was retracting them | The retract hooks in Module 7 are not optional polish; without them persistence becomes a chore | 7 |
| 10 | Alert style silently reverts to Banners | Changing `BUNDLE_ID` later makes macOS treat it as a **brand-new app**, re-seeded and re-listed, with the old entry lingering in System Settings | Pick the id once and keep it. Re-running the installer with the same id is safe and preserves the style | 7 |
| 11 | Duplicated tmux hooks after every config reload | `set-hook -ga` **appends** | Use `set-hook -g` (plain) as shown | 5 |
| 12 | A Claude instance never appears | Discovery maps process TTY → tmux pane, so instances **outside tmux** are invisible, as are ones on another host or tmux server | Expected limitation; run Claude inside tmux | 2 |
| 13 | `prefix + a` does nothing / hits a plugin | Key already bound (e.g. `t-smart-tmux-session-manager` binds `T`) | Check `tmux list-keys -T prefix \| grep " a "` and pick a free key | 8 |
| 14 | Editing the `.app` "breaks its signature" | Homebrew's build is **ad-hoc, linker-signed, `Info.plist=not bound`** — editing `Info.plist` is safe and needs no re-sign | Confirm with `codesign -dv <app>`. If yours shows a real TeamIdentifier / sealed resources, re-sign after editing: `codesign --force --sign - <app>` | 7 |
| 15 | Status bar truncates | tmux `status-right-length` defaults to 40 | Set it to ~120 | 4 |
Two smaller ones: state markers are **dotfiles on purpose** (`.viewed-`/`.notify-`) so the
`"$dir"/*` glob that walks state files skips them; and this is **`~/.claude`-global**, monitoring
all your Claude sessions rather than one project.
---
## Dependencies at a glance
| Module | Hard dependencies | Alternatives / notes |
| --------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1 — State tracking | Claude Code CLI, `jq`, bash | None; this is the foundation |
| 2 — Process discovery | `ps`, `awk`, `sort`, `grep`, tmux | `ps -Ao tty,command` works on macOS + Linux; adjust flags for an unusual `ps` (e.g. `-o tty,cmd`) |
| 3 — Renderer | Modules 1–2, tmux, `awk`, `grep` | — |
| 4 — tmux wiring | tmux (any modern version) | — |
| 5 — Viewed collapse | tmux with `focus-events on`, `set-hook` (tmux ≥ 3.0) | Skip it and finished instances stay `✅` until the session ends |
| 6 — Notifications | macOS; `terminal-notifier` **or** built-in `osascript`; AppleScript **Automation** permission for the terminal | Linux: swap `notify()` for `notify-send` and `user_is_looking()` for your WM's focus query (or always return false) |
| 7 — Persistent alerts | Module 6, `terminal-notifier` (**not** the `osascript` fallback), `plutil`, `lsregister` | macOS-only. Module 5 too, for view-triggered dismissal — see the module's note |
| 8 — Switcher | tmux ≥ 3.2 (`display-popup`), `fzf` | `tmux display-menu` (no fuzzy search, no preview) |
Author's baseline for reference: macOS 15.6, Ghostty 1.2.3 (quick-terminal/quake mode), tmux 3.6a,
fish as the interactive shell (irrelevant — every script is bash), Homebrew at `/opt/homebrew`.
Substitute freely; the only environment values that must match reality are `TERMINAL_APP` and any
absolute tool paths.
---
## The implementation
Create `~/.claude/hooks/` and `~/.claude/status/` if absent, and `chmod +x` every script.
Paths use `$HOME` throughout — every one of these runs through a shell (tmux `#(...)`,
`run-shell`, `display-popup -E`, Claude Code hook commands), so it expands. If a path fails to
resolve, substitute an absolute home directory and re-test.
### Module 1 — State tracking via Claude Code hooks (core)
On each Claude lifecycle event, write `~/.claude/status/<session-id>` = `state|epoch|pane|cwd`,
where state is `running` / `permission` / `done`; `clear` deletes it. Files: `claude-status.sh`
(below, shared with Modules 6–7) + the hooks block in `~/.claude/settings.json`.
### Module 2 — Process discovery (core)
Prints the tmux `pane_id` of every live `claude` process by matching process TTY to pane TTY.
**This is what makes idle instances appear at all** — a purely event-driven design misses them,
and shows a stuck "working" indicator after an ESC interrupt.
`~/.claude/hooks/claude-live-panes.sh`:
```bash
#!/usr/bin/env bash
# Outputs the tmux pane_id (one per line) of every live Claude Code CLI instance,
# by matching each `claude` process's TTY to a tmux pane's TTY. Authoritative and
# self-correcting: finds idle instances and ones with no state file.
# TTYs of live `claude` CLI processes. `$1 != "??"` drops the tty-less
# chrome-native-host helper; the basename check keeps only the real CLI.
ttys="$(ps -Ao tty,command 2>/dev/null | awk '
$1 != "??" {
n = split($2, p, "/")
if (p[n] == "claude") print $1
}
' | sort -u)"
[ -n "$ttys" ] || exit 0
# Emit the pane whose tty matches each claude tty (pane_tty is /dev/ttysNNN).
tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null | while read -r ptty pid; do
printf '%s\n' "$ttys" | grep -qxF "${ptty#/dev/}" && printf '%s\n' "$pid"
done
```
### Module 3 — Status-line renderer (core)
For each discovered pane: footer says working → `●`; otherwise overlay the newest state file for
that pane. Three signals, each used for what it's best at — **which instances exist** comes from
process discovery, **is it working right now** from the pane's own footer (never stuck, unlike an
event flag after an interrupt), **everything else** from hook state files.
`~/.claude/hooks/claude-tmux-status.sh`:
```bash
#!/usr/bin/env bash
# Prints the one-line Claude status summary for tmux status-right.
# 1. Which instances exist -> claude-live-panes.sh (TTY -> pane).
# 2. Is it working right now -> the pane footer shows "esc to interrupt".
# 3. Other states -> hook state files ~/.claude/status/<sid>.
# Layout: ●N 👁M ✋<perm names> ✅<done names, cap then ✅+K> (counters lead).
# Written for macOS's stock bash 3.2.
dir="$HOME/.claude/status"
panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"
DONE_CAP=4
WORKING_MARK='esc to interrupt' # Claude's footer shows this only while processing
live_panes=" $(tmux list-panes -a -F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "
# Load state files into arrays; prune files whose pane is truly gone.
n=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r st ts pn cw < "$f"
sid="$(basename "$f")"
if [ -n "$pn" ]; then
case "$live_panes" in
*" $pn "*) : ;;
*) rm -f "$f" "$dir/.viewed-$sid" "$dir/.notify-$sid"; continue ;;
esac
fi
A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_SID[$n]="$sid"
n=$((n + 1))
done
running=0; viewed=0; done_count=0; perm_l=""; done_l=""
pane_working() { # 0 (true) if the pane's footer shows Claude is processing
tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"
}
classify_idle() { # state sid label -- for a pane that is NOT actively working
local state="$1" sid="$2" label="$3"
case "$state" in
permission) perm_l="$perm_l #[fg=colour208,bold]✋${label}#[default]" ;;
done)
if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then
viewed=$((viewed + 1))
else
done_count=$((done_count + 1))
[ "$done_count" -le "$DONE_CAP" ] && done_l="$done_l #[fg=colour46,bold]✅${label}#[default]"
fi ;;
*) viewed=$((viewed + 1)) ;; # idle / stuck-running / waiting / untracked
esac
}
for pane in $(bash "$panes_helper" 2>/dev/null); do
label="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)"
[ -n "$label" ] || label="claude"
if pane_working "$pane"; then
running=$((running + 1)); continue
fi
bi=-1; bts=-1; k=0
while [ "$k" -lt "$n" ]; do
if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi
k=$((k + 1))
done
if [ "$bi" -ge 0 ]; then classify_idle "${A_STATE[$bi]}" "${A_SID[$bi]}" "$label"
else classify_idle "idle" "" "$label"; fi
done
if [ "$done_count" -gt "$DONE_CAP" ]; then
done_l="$done_l #[fg=colour46,bold]✅+$((done_count - DONE_CAP))#[default]"
fi
out=""
[ "$running" -gt 0 ] && out="$out #[fg=colour39]●${running}#[default]"
[ "$viewed" -gt 0 ] && out="$out #[fg=colour244]👁${viewed}#[default]"
out="$out$perm_l$done_l"
if [ -n "$out" ]; then printf '%s' "${out# }"
else printf '#[fg=colour240]· claude idle#[default]'; fi
```
### Module 4 — tmux wiring (core)
See the `~/.tmux.conf` block at the end of this section; Module 4 is the first two lines.
### Module 5 — "Viewed" collapse (optional)
When you **focus** a finished instance's pane, drop a `.viewed-<id>` marker so `✅name` collapses
into the `👁` count — gated on real focus, so an instance finishing while your terminal is
backgrounded stays a visible `✅`. The same hook retracts that instance's alert (Module 7).
Skipping this: also drop the five `set-hook` lines from `~/.tmux.conf`, and Module 7 loses
view-triggered dismissal (it still retracts on `running`/`clear`).
`~/.claude/hooks/claude-mark-viewed.sh`:
```bash
#!/usr/bin/env bash
# Marks finished ('done') Claude sessions as "viewed" once you actually focus
# their pane, so they collapse from the named list into the 👁 counter.
#
# Invoked (backgrounded) by tmux focus/navigation hooks in ~/.tmux.conf:
# after-select-pane, after-select-window, client-session-changed,
# client-attached, client-focus-in.
# Using client-focus-in means "viewed" only counts when the terminal itself
# has OS focus -- a task finishing while the terminal is backgrounded is NOT hidden.
dir="$HOME/.claude/status"
[ -d "$dir" ] || exit 0
# Focused pane(s): the active pane of the active window of each attached session.
focused=" $(tmux list-panes -a \
-f '#{&&:#{pane_active},#{&&:#{window_active},#{session_attached}}}' \
-F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "
changed=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r state ts pane cwd < "$f"
[ -n "$pane" ] || continue
case "$focused" in *" $pane "*) ;; *) continue ;; esac
sid="$(basename "$f")"
[ "$state" = "done" ] && { marker="$dir/.viewed-$sid"; [ -f "$marker" ] || { touch "$marker"; changed=1; }; }
# --- Module 7: looking at the pane means you've seen it, so retract its macOS alert.
# Those are manual-dismiss and would otherwise pile up until clicked. .notify-<sid>
# doubles as "an alert is currently posted", so this fires once and only when there's
# one to pull down. Applies to `permission` too: the ✋ in the bar is the standing
# reminder. Delete this block if you skip Module 7.
if [ -f "$dir/.notify-$sid" ]; then
"$HOME/.claude/hooks/claude-notify-clear.sh" "$sid"
rm -f "$dir/.notify-$sid"
fi
done
# Only force a redraw when something actually changed.
[ "$changed" -eq 1 ] && tmux refresh-client -S 2>/dev/null
exit 0
```
### Module 6 — Desktop notifications (optional, macOS as written)
Notify on `permission`/`done` **only when the terminal isn't frontmost** (so you're never spammed
while watching), de-duplicated per session, silent. Lives inside `claude-status.sh` below.
### Module 7 — Persistent alerts that retract themselves (optional, macOS)
**The problem it solves:** a banner auto-fades after a few seconds. If you're away from the desk
when an agent finishes, you miss it entirely and the whole notification is wasted. macOS's
**Alerts** style stays on screen until dismissed — but alert style is a **per-app OS setting**,
not something a notification payload can request.
**Why a private app bundle:** macOS seeds an app's style from `NSUserNotificationAlertStyle` in
its `Info.plist`, but **only the first time that bundle id registers**. Homebrew's
`terminal-notifier` is long since registered as `banner`, so editing its plist does nothing — and
flipping its style by hand in System Settings would make _every_ script that uses it persistent.
So: a copy of the bundle with its own `CFBundleIdentifier`. It also makes notifications read as
**"Claude Code"** rather than "terminal-notifier".
**Why it auto-retracts:** persistence without retraction just moves the chore. Every alert is
tagged `-group claude-<session-id>`, which is a precise handle to pull that one back down, from
three places: you focused that instance's pane (Module 5), work resumed, or the session ended.
Only the _focused_ instance's alert is retracted — the others keep standing, which is the point.
`~/.claude/hooks/claude-notifier-install.sh` — run once (and again after
`brew upgrade terminal-notifier`):
```bash
#!/usr/bin/env bash
# (Re)builds ~/.claude/bin/ClaudeNotify.app -- a private copy of terminal-notifier.app used
# by claude-status.sh to post macOS notifications that DO NOT auto-dismiss.
#
# Why a copy: macOS alert style is per-app, and an app's *initial* style is seeded from
# NSUserNotificationAlertStyle in its Info.plist the first time it registers. Homebrew's
# terminal-notifier ships "banner" and is already registered, so the only way to get
# persistent Alerts without hijacking every other terminal-notifier caller is a bundle with
# its own CFBundleIdentifier. Bonus: notifications are attributed to "Claude Code".
#
# Run again after `brew upgrade terminal-notifier` to pick up the new binary. The bundle id
# stays the same, so the alert style already chosen in System Settings is preserved.
set -euo pipefail
# Any reverse-DNS id you have NEVER used before -- the unused-ness is what makes the style
# seeding work. Put your own name/domain in it. Don't change it later (see gotcha 10).
BUNDLE_ID="com.example.claude-notify"
BUNDLE_NAME="Claude Code"
DST="$HOME/.claude/bin/ClaudeNotify.app"
src_bin="$(command -v terminal-notifier || true)"
[ -n "$src_bin" ] || { echo "terminal-notifier not found -- brew install terminal-notifier" >&2; exit 1; }
# Locate terminal-notifier.app. Homebrew's bin/terminal-notifier is a two-line wrapper that
# execs the binary inside the bundle, so read the path straight out of it; other install
# layouts put the CLI inside the bundle itself, so fall back to resolving the symlink.
SRC="$(sed -n 's|.*"\(/.*\.app\)/Contents/MacOS/[^"]*".*|\1|p' "$src_bin" 2>/dev/null | head -1)"
if [ ! -d "${SRC:-}" ]; then
real="$(readlink -f "$src_bin" 2>/dev/null || python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$src_bin")"
case "$real" in */Contents/MacOS/*) SRC="${real%/Contents/MacOS/*}" ;; esac
fi
[ -d "${SRC:-}" ] || { echo "could not locate terminal-notifier.app (from $src_bin)" >&2; exit 1; }
mkdir -p "$(dirname "$DST")"
rm -rf "$DST"
cp -R "$SRC" "$DST"
plist="$DST/Contents/Info.plist"
plutil -replace CFBundleIdentifier -string "$BUNDLE_ID" "$plist"
plutil -replace CFBundleName -string "$BUNDLE_NAME" "$plist"
plutil -replace NSUserNotificationAlertStyle -string "alert" "$plist"
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$DST"
echo "built $DST ($BUNDLE_ID)"
echo "verify: System Settings > Notifications > \"$BUNDLE_NAME\" -> Alert style = Alerts"
"$DST/Contents/MacOS/terminal-notifier" -title "Claude Code" -message "Persistent alert test -- this should stay until dismissed" -group claude-install-test
```
`~/.claude/hooks/claude-notify-clear.sh`:
```bash
#!/usr/bin/env bash
# Pulls down the macOS alert(s) that claude-status.sh posted for the given session id(s).
# Usage: claude-notify-clear.sh <session-id> [session-id...]
#
# The alerts are manual-dismiss by design (see claude-notifier-install.sh), so something has
# to retract them once they're moot. Callers:
# * claude-mark-viewed.sh -- you focused that instance's pane, so you've seen it
# * claude-status.sh -- the session went `running` again, or ended (`clear`)
#
# NSUserNotificationCenter only manages its own app's delivered notifications, so the remove
# MUST go through the same bundle that posted -- hence the ClaudeNotify.app path first, with
# the same fallback order as claude-status.sh's notify().
set -u
bin="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"
[ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)"
[ -n "$bin" ] || exit 0
for sid in "$@"; do
[ -n "$sid" ] || continue
"$bin" -remove "claude-$sid" >/dev/null 2>&1 || true
done
exit 0
```
**One manual step, once:** after the installer's test notification appears, open
**System Settings → Notifications → "Claude Code"** and confirm **Alert style = Alerts**. The
`Info.plist` seeding normally sets it; the dropdown is the ground truth, and one click fixes it
permanently if it reads Banners.
### Modules 1 + 6 + 7 — `~/.claude/hooks/claude-status.sh`
The shared entry point. Module boundaries are marked; delete a marked block to skip that module.
```bash
#!/usr/bin/env bash
# Records this Claude session's status for the tmux indicator, and fires a
# macOS notification when the session goes idle while the terminal is not the
# frontmost app (closed or tabbed away from).
# Usage: claude-status.sh <running|permission|done|clear>
# Hook JSON (with .session_id and .cwd) is read from stdin.
state="${1:-}"
dir="$HOME/.claude/status"
mkdir -p "$dir"
input="$(cat)"
sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null)"
[ -n "$sid" ] || sid="unknown"
file="$dir/$sid"
notif_marker="$dir/.notify-$sid"
viewed_marker="$dir/.viewed-$sid"
cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
# --- Module 7: an alert from the last idle may still be on screen (they're manual-dismiss).
# Work resuming or the session ending makes it moot, so retract it. Gated on the marker
# existing, since `running` fires on every tool call and must not spawn the notifier each
# time. Delete this block if you skip Module 7.
case "$state" in
running|clear)
[ -f "$notif_marker" ] && "$HOME/.claude/hooks/claude-notify-clear.sh" "$sid"
;;
esac
# --- write / clear state for the tmux status bar (Module 1) ---
if [ "$state" = "clear" ]; then
rm -f "$file" "$notif_marker" "$viewed_marker"
else
# state | epoch | tmux pane (empty when not in tmux) | cwd -- written atomically
# so the renderer / mark-viewed hook never see a half-written line.
printf '%s|%s|%s|%s\n' "$state" "$(date +%s)" "${TMUX_PANE:-}" "$cwd" > "$file.tmp" \
&& mv -f "$file.tmp" "$file"
fi
# New work resumes: reset the notify marker (so the next idle notifies again) and
# drop the viewed marker (a fresh cycle should re-show, not stay collapsed).
[ "$state" = "running" ] && rm -f "$notif_marker" "$viewed_marker"
# ============ Module 6 (+7): notify when idle and you're not looking ============
TERMINAL_APP="ghostty" # System Events process name of YOUR terminal (case-insensitive)
# Module 7's private bundle: same terminal-notifier binary, but its own CFBundleIdentifier
# and NSUserNotificationAlertStyle=alert, so what it posts stays on screen until dismissed.
# Falls back to plain terminal-notifier (banners) if Module 7 isn't installed.
NOTIFIER_BIN="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"
notify() {
local title="$1" msg="$2" bin="$NOTIFIER_BIN"
[ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)"
if [ -n "$bin" ]; then
# Plain, non-blocking, silent. -sender/-activate can hang; don't add them.
# For it to actually POP (not just land in Notification Center):
# 1. alert style is Banners or Alerts, not None (System Settings > Notifications)
# 2. "Allow notifications when mirroring or sharing the display" = ON
# -- macOS hides ALL notifications while the screen is mirrored/recorded otherwise.
# -group is also the handle claude-notify-clear.sh uses to pull the alert back down.
"$bin" -title "$title" -message "$msg" -group "claude-$sid" >/dev/null 2>&1 || true
else
local t="${title//\\/\\\\}"; t="${t//\"/\\\"}"
local m="${msg//\\/\\\\}"; m="${m//\"/\\\"}"
osascript -e "display notification \"$m\" with title \"$t\"" 2>/dev/null || true
fi
}
# Returns 0 when you ARE looking at the terminal (it's frontmost) -> suppress notify.
user_is_looking() {
local front
front="$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null)"
if [ -n "$front" ]; then
[ "$(printf '%s' "$front" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$TERMINAL_APP" | tr '[:upper:]' '[:lower:]')" ]
return
fi
# Frontmost unknown (e.g. Automation permission denied) -> fall back to tmux clients.
if [ -n "$TMUX" ]; then
local clients; clients="$(tmux list-clients 2>/dev/null | wc -l | tr -d ' ')"
[ "${clients:-0}" -gt 0 ]; return
fi
return 1
}
case "$state" in
done|permission)
# Notify only when you're NOT looking at the terminal, and dedupe on state change.
last="$(cat "$notif_marker" 2>/dev/null || true)"
if ! user_is_looking && [ "$state" != "$last" ]; then
proj="$(basename "$cwd" 2>/dev/null)"
[ -n "$proj" ] && [ "$proj" != "." ] || proj="claude"
if [ "$state" = "permission" ]; then
notify "Claude · needs approval" "✋ $proj needs your approval"
else
notify "Claude · done" "✅ $proj finished — your turn"
fi
printf '%s' "$state" > "$notif_marker"
fi
;;
esac
# ====================== end Modules 6/7 ======================
# Redraw the tmux status bar immediately (server-wide, best-effort).
tmux refresh-client -S 2>/dev/null || true
exit 0
```
### Module 8 — Jump-to switcher popup (optional)
`prefix + a` → fzf popup of every instance (state emoji, `session:window.pane`, project, age)
with a live preview of that pane; `Enter` jumps to it.
`~/.claude/hooks/claude-switcher.sh`:
```bash
#!/usr/bin/env bash
# fzf popup listing every live Claude instance; Enter jumps to its pane.
# Bound to `prefix + a` via display-popup.
dir="$HOME/.claude/status"
panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"
WORKING_MARK='esc to interrupt'
FZF=fzf
command -v fzf >/dev/null 2>&1 || FZF=/opt/homebrew/bin/fzf # fallback path; adjust if needed
now="$(date +%s)"
n=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r st ts pn cw < "$f"
A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_CWD[$n]="$cw"; A_SID[$n]="$(basename "$f")"
n=$((n + 1))
done
pane_working() { tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"; }
rows=""
for pane in $(bash "$panes_helper" 2>/dev/null); do
bi=-1; bts=-1; k=0
while [ "$k" -lt "$n" ]; do
if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi
k=$((k + 1))
done
if [ "$bi" -ge 0 ]; then st="${A_STATE[$bi]}"; ts="${A_TS[$bi]}"; cwd="${A_CWD[$bi]}"; sid="${A_SID[$bi]}"
else st="idle"; ts=0; cwd=""; sid=""; fi
if pane_working "$pane"; then
emoji="●"; status="working"
else
case "$st" in
permission) emoji="✋"; status="needs approval" ;;
done) if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then emoji="👁"; status="idle"; else emoji="✅"; status="done"; fi ;;
*) emoji="👁"; status="idle" ;;
esac
fi
loc="$(tmux display-message -p -t "$pane" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)"
[ -n "$loc" ] || loc="$pane"
if [ -n "$cwd" ]; then proj="$(basename "$cwd")"
else proj="$(basename "$(tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null)")"; fi
[ -n "$proj" ] && [ "$proj" != "." ] || proj="—"
if [ "$status" != "working" ] && [ "$ts" -gt 0 ]; then
age=$(( now - ts ))
if [ "$age" -lt 60 ]; then when="${age}s ago"
elif [ "$age" -lt 3600 ]; then when="$((age / 60))m ago"
else when="$((age / 3600))h ago"; fi
else when="$status"; fi
rows="$rows${pane}"$'\t'"$emoji $loc $proj $when"$'\n'
done
if [ -z "$rows" ]; then printf 'No Claude instances.\n'; sleep 1; exit 0; fi
sel="$(printf '%s' "$rows" | "$FZF" \
--ansi --delimiter='\t' --with-nth=2.. --prompt='claude ▸ ' \
--preview 'tmux capture-pane -pe -J -t {1}' --preview-window=right:60%)"
[ -n "$sel" ] || exit 0
pane="$(printf '%s' "$sel" | cut -f1)"
[ -n "$pane" ] || exit 0
sess="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)"
tmux switch-client -t "$sess" \; select-window -t "$pane" \; select-pane -t "$pane"
```
### `~/.claude/settings.json` — hooks block (merge into any existing file)
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh permission"
}
]
},
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh done"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh done"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh clear"
}
]
}
]
}
}
```
- **`PostToolUse → running` matters:** `PreToolUse` fires _before_ a permission prompt, so
`PostToolUse` is what clears the `permission` state after you approve.
- **The `Notification` matcher split** is the reliable way to distinguish a permission prompt from
an idle "your turn" — do **not** regex the notification message text. `matcher` goes on the
group object, exactly as shown.
### `~/.tmux.conf` — additions
```tmux
# --- Claude status indicator (Module 4) ---
set -g status-right "#($HOME/.claude/hooks/claude-tmux-status.sh)"
set -g status-right-length 120 # default 40 truncates once several sessions are open
# Module 5: collapse a finished instance into 👁 once you focus its pane (and retract its
# alert, Module 7). focus-events must be on for client-focus-in to fire.
set -g focus-events on
set-hook -g after-select-pane 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g after-select-window 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-session-changed 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-attached 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-focus-in 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
# Module 8: popup switcher (pick a key that's free for you; verify prefix+a is unbound).
bind-key a display-popup -E -w 80% -h 60% "$HOME/.claude/hooks/claude-switcher.sh"
```
Reload with `tmux source-file ~/.tmux.conf`. Use plain `set-hook -g` (not `-ga`) so reloading
doesn't stack duplicate hooks; these hook names are unset by default and unused by common plugins.
---
## Install order
**Install all of it unless the user said otherwise.** Foundation first, so there's something
visible to demo at every step.
1. `mkdir -p ~/.claude/hooks ~/.claude/status` — and `chmod +x ~/.claude/hooks/*.sh` as you go.
2. **Modules 1–4 (core).** `claude-status.sh` (you can paste the full version now; Modules 6/7's
blocks are inert until their scripts exist), `claude-live-panes.sh`,
`claude-tmux-status.sh`, the settings.json hooks, the first two `~/.tmux.conf` lines.
Reload tmux, **start a fresh Claude session** (gotcha 2) — the bar should light up. _Demoable._
3. **Module 5** — `claude-mark-viewed.sh` + the five `set-hook` lines. _Demoable: `✅` → `👁`._
4. **Module 6** — set `TERMINAL_APP`; grant the terminal **Automation** permission for System
Events on first prompt; on macOS enable _Allow notifications when mirroring or sharing the
display_ (gotcha 5). _Demoable: tab away, wait for a task to finish._
5. **Module 7** — `brew install terminal-notifier`, add `claude-notify-clear.sh`, set `BUNDLE_ID`
in `claude-notifier-install.sh`, run it, then confirm the alert style in System Settings.
_Demoable: an alert that outlives a banner and vanishes when you look at its pane._
6. **Module 8** — `claude-switcher.sh` + the `bind-key`; confirm the key is free first.
---
## Verification quick-checks
| Run this | Expect |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `bash ~/.claude/hooks/claude-live-panes.sh` | one pane id per live Claude instance. Cross-check: `tmux list-panes -a -F '#{pane_id} #{session_name}'` |
| `~/.claude/hooks/claude-tmux-status.sh \| sed -E 's/#\[[^]]*\]//g'` | the rendered line, colours stripped, e.g. `●1 👁2` |
| Start a task in a Claude pane | `●` appears for it; on finish it becomes `✅<session>` |
| Trigger a tool needing approval (in a **fresh** session) | `✋<session>`; approve → `●` → `✅` |
| Focus a `✅` instance's pane | it folds into the `👁` count |
| `prefix + a` | popup lists every instance with a live preview; `Enter` jumps there |
| Tab away from the terminal, let a task finish | one notification, titled **Claude Code** |
| `~/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier -list ALL` | a row per still-posted alert, with group `claude-<session-id>` |
| Leave an alert for 30s without touching it | still on screen — that's Alerts style working (a banner would be long gone) |
| Now focus that instance's pane | alert disappears on its own; `-list ALL` no longer shows it |
| `defaults read ~/.claude/bin/ClaudeNotify.app/Contents/Info.plist NSUserNotificationAlertStyle` | `alert` |
**How the known failure modes present:**
- **No notification at all, everything else fine** → mirroring (gotcha 5) first, then Focus mode,
then alert style set to _None_ in System Settings.
- **Notification appears but fades in ~5s** → the bundle's style is Banners, not Alerts: check the
System Settings dropdown for "Claude Code" (gotcha 6). Confirm you're posting through
`ClaudeNotify.app`, not the Homebrew `terminal-notifier` on `PATH`.
- **Alerts appear but never retract** → `-remove` going through the wrong bundle (gotcha 7), or a
stale/missing `.notify-<id>` marker (`ls -a ~/.claude/status/`).
- **`●` never appears, other states fine** → the footer string changed (gotcha 1).
- **`✋`/`✅` never appear for one session** → it predates the hook config (gotcha 2).
---
## Optional extensions
- **Emojis/colours:** the glyphs and `#[fg=colourNNN]` codes in `claude-tmux-status.sh` /
`claude-switcher.sh` (tmux 256-colour palette). `DONE_CAP` controls how many `✅` names show
before collapsing to `✅+K`.
- **Name idle instances too:** move that case out of the `*)` count in `classify_idle`.
- **Click-to-jump alerts:** `terminal-notifier -execute '<command>'` runs a shell command when the
alert is clicked, so a click could switch tmux to the pane that raised it. Worth knowing if you
use a quake/dropdown terminal: `open -a <Terminal>` only activates the app and can't summon a
dropdown window — Ghostty 1.2.3 exposes a `QuickTerminalIntent` App Intent ("Open the Quick
Terminal… if already open, do nothing") reachable from a one-action Shortcut via
`shortcuts run "<name>"`; there is no CLI equivalent (`ghostty +new-window` is GTK/D-Bus,
Linux-only). Deliberately **not** installed here — auto-retract on view proved cleaner than
making the alert something you must click.
- **No tmux?** Modules 1, 6 and 7 work standalone; build a different front-end (menu-bar app,
notification-only setup) on top of the `~/.claude/status/` files.
# Multi-instance Claude Code status indicator for tmux (+ persistent macOS alerts & jump-to switcher)
A tmux status-bar indicator that summarises **every live Claude Code CLI instance at a glance** plus macOS
notifications that **stay on screen until dismissed** (and retract themselves once you look at the
instance they came from), and an fzf popup to jump straight to any instance's pane. Built and
verified on **macOS 15.6 + Ghostty + tmux 3.6a + stock bash 3.2 + Claude Code CLI**; the core is
portable, the notification module is macOS-specific and called out as such.
---
## Quick reference
**The status bar** (`status-right`), e.g. `●2 👁3 ✋games ✅notes`:
| Element | Meaning |
| ----------------- | -------------------------------------------------- |
| `●N` (blue) | N instances **actively processing right now** |
| `👁M` (grey) | M instances **idle / already seen** |
| `✋name` (orange) | instance **blocked waiting for your approval** |
| `✅name` (green) | instance that **just finished**, not yet looked at |
| `· claude idle` | nothing running |
Counters lead so they survive truncation. `name` is the **tmux session name**. `✅` collapses into
`👁` once you focus that pane. More than 4 finished at once collapse to `✅+K`.
**Keys and commands**
| Action | How |
| -------------------------------- | --------------------------------------------------------- |
| Jump to any instance | `prefix + a` → fzf popup (live pane preview) → `Enter` |
| Dismiss one alert | Look at that instance's pane — it retracts itself |
| Dismiss all alerts for a session | Send it new work (`running` retracts), or end the session |
| Force a status-bar redraw | `tmux refresh-client -S` |
| Rebuild the notifier app | `~/.claude/hooks/claude-notifier-install.sh` |
**The day-to-day loop:** fire off work in several panes → tab away to a browser/editor → an alert
sits on screen per finished-or-blocked instance until you deal with it → open your terminal, look
at that pane, its alert disappears and its `✅` folds into `👁` → `prefix + a` to hop to the next.
**Where things live**
| Path | What |
| -------------------------------- | ------------------------------------------- |
| `~/.claude/hooks/*.sh` | all scripts |
| `~/.claude/status/<session-id>` | state file, `state\|epoch\|pane\|cwd` |
| `~/.claude/status/.viewed-<id>` | "you've looked at this" marker |
| `~/.claude/status/.notify-<id>` | "an alert is currently posted" marker |
| `~/.claude/bin/ClaudeNotify.app` | private notifier bundle (Module 7) |
| `~/.claude/settings.json` | hook wiring |
| `~/.tmux.conf` | status-right, focus hooks, switcher binding |
---
## What this is and why
People increasingly run **several Claude Code CLI sessions at once** — one per project, worktree,
or tmux pane. You lose track of them. Which are actively working? Which are blocked waiting for
you to approve a command? Which finished five minutes ago and are just sitting there? You end up
cycling through panes to check, and an agent that finished while you were in a browser can idle
for a long time before you notice.
This solves that with four things:
1. A **tmux status-bar indicator** summarising every live instance at a glance.
2. **macOS alerts** when an instance finishes or needs approval _while you're not looking at the
terminal_ — deliberately **manual-dismiss**, so they're still there when you come back.
3. **Automatic retraction** of an alert once you've actually seen that instance, so persistence
never turns into a pile of stale alerts to clear by hand.
4. A **popup switcher** to jump straight to any instance's pane.
The design point worth keeping: the status bar answers _"what's the state of everything?"_ at a
glance, and the alerts answer _"do I need to come back to the terminal at all?"_ when you're
elsewhere.
---
## For the implementing agent (read this first)
> **Default: build this as specified — then check in about variations.**
> The reader most likely saw this demonstrated and wants the same thing. Reproduce it as
> described. The defaults encode decisions arrived at by hitting the problems in _Known gotchas_ —
> treat them as load-bearing.
>
> **Do not open with "so which parts would you like?"** — that dumps design work on someone who
> just wants it working. Instead:
>
> 1. **State the plan and confirm it** — one question, phrased so "yes, all of it" is the easy
> answer. For example, verbatim: _"I'll install the full setup: the tmux status indicator for
> all your Claude instances, persistent macOS alerts that auto-dismiss once you view the
> instance, and a `prefix + a` popup switcher. Eight modules, roughly 15 minutes, and I'll
> verify each one as I go. Sound good?"_
> 2. **Interview only where their environment forces a decision** — not macOS (Module 6/7 are
> macOS-only as written), not tmux, a different terminal app (`TERMINAL_APP` must match their
> terminal's process name), `prefix + a` already bound, an existing `~/.claude/settings.json`
> with a `hooks` key to merge into, no `jq`/`fzf`. These are _compatibility_ questions, not
> preference questions. Don't silently substitute — ask, then adapt.
> 3. **Call out the genuinely optional pieces**, so opting out is cheap and informed: Module 5
> (viewed-collapse), Module 6+7 (notifications), Module 8 (switcher). Modules 1–4 are the core
> and only make sense together.
> 4. **Then invite variations** — and build them if asked. Don't propose a redesign unprompted.
>
> Deviate when the user asks or their environment requires it. Not because something looks
> tidier — that's how the gotchas get reintroduced.
The modules below are independently installable, so a user who wants only the status bar, or only
the notifications, can have exactly that — but that's their call to make, not your opening move.
---
## Known gotchas
**Load-bearing — don't simplify these away.** Each cost real debugging time.
| # | Symptom | Cause | Fix | Module |
| --- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ |
| 1 | `●` never lights up | "Working" is detected by grepping the pane's footer for `esc to interrupt`, a Claude TUI string, not a public API | Update `WORKING_MARK` at the top of `claude-tmux-status.sh` **and** `claude-switcher.sh` | 3, 8 |
| 2 | `✋`/`✅` never appear for a session | Hook config only applies to **newly started** Claude sessions | Restart the session. Discovery still lists old ones as `👁` — that's intended | 1 |
| 3 | Scripts fail with `declare -A` / substitution errors | macOS ships **bash 3.2** | Scripts avoid associative arrays and modern syntax on purpose; keep that if you edit them | all |
| 4 | Notifier call hangs the hook | `terminal-notifier`'s `-sender` / `-activate` flags can hang | Use only `-title/-message/-group` (and `-remove`) as shown | 6, 7 |
| 5 | Everything looks correctly configured but **no notification ever pops** | macOS suppresses **all** notifications — banners _and_ alerts — while the display is **mirrored or shared**, silently routing them to Notification Center. Costs hours if you record screencasts | System Settings → Notifications → **"Allow notifications when mirroring or sharing the display" = ON**. Diagnose with `system_profiler SPDisplaysDataType \| grep -i mirror` | 6, 7 |
| 6 | Editing Homebrew `terminal-notifier.app`'s `Info.plist` to `alert` changes nothing | macOS seeds an app's alert style from `NSUserNotificationAlertStyle` **only on that bundle id's first registration**. An already-registered app ignores it forever | Use a **copy with its own unused `CFBundleIdentifier`** (Module 7). This is the whole reason that module exists — not cosmetics | 7 |
| 7 | `-remove` silently does nothing | `NSUserNotificationCenter` only manages **its own app's** delivered notifications | The retract must run through the **same bundle that posted** — `ClaudeNotify.app`'s binary, not the Homebrew `terminal-notifier` on `PATH` | 7 |
| 8 | Notifier process spawns constantly / hooks feel sluggish | `running` fires on **every tool call**; an ungated retract launches a GUI binary each time | Gate every retract on `.notify-<id>` existing — it doubles as "an alert is currently posted" | 7 |
| 9 | Alerts pile up unread even after you've seen them | Manual-dismiss is the point, but nothing was retracting them | The retract hooks in Module 7 are not optional polish; without them persistence becomes a chore | 7 |
| 10 | Alert style silently reverts to Banners | Changing `BUNDLE_ID` later makes macOS treat it as a **brand-new app**, re-seeded and re-listed, with the old entry lingering in System Settings | Pick the id once and keep it. Re-running the installer with the same id is safe and preserves the style | 7 |
| 11 | Duplicated tmux hooks after every config reload | `set-hook -ga` **appends** | Use `set-hook -g` (plain) as shown | 5 |
| 12 | A Claude instance never appears | Discovery maps process TTY → tmux pane, so instances **outside tmux** are invisible, as are ones on another host or tmux server | Expected limitation; run Claude inside tmux | 2 |
| 13 | `prefix + a` does nothing / hits a plugin | Key already bound (e.g. `t-smart-tmux-session-manager` binds `T`) | Check `tmux list-keys -T prefix \| grep " a "` and pick a free key | 8 |
| 14 | Editing the `.app` "breaks its signature" | Homebrew's build is **ad-hoc, linker-signed, `Info.plist=not bound`** — editing `Info.plist` is safe and needs no re-sign | Confirm with `codesign -dv <app>`. If yours shows a real TeamIdentifier / sealed resources, re-sign after editing: `codesign --force --sign - <app>` | 7 |
| 15 | Status bar truncates | tmux `status-right-length` defaults to 40 | Set it to ~120 | 4 |
Two smaller ones: state markers are **dotfiles on purpose** (`.viewed-`/`.notify-`) so the
`"$dir"/*` glob that walks state files skips them; and this is **`~/.claude`-global**, monitoring
all your Claude sessions rather than one project.
---
## Dependencies at a glance
| Module | Hard dependencies | Alternatives / notes |
| --------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 1 — State tracking | Claude Code CLI, `jq`, bash | None; this is the foundation |
| 2 — Process discovery | `ps`, `awk`, `sort`, `grep`, tmux | `ps -Ao tty,command` works on macOS + Linux; adjust flags for an unusual `ps` (e.g. `-o tty,cmd`) |
| 3 — Renderer | Modules 1–2, tmux, `awk`, `grep` | — |
| 4 — tmux wiring | tmux (any modern version) | — |
| 5 — Viewed collapse | tmux with `focus-events on`, `set-hook` (tmux ≥ 3.0) | Skip it and finished instances stay `✅` until the session ends |
| 6 — Notifications | macOS; `terminal-notifier` **or** built-in `osascript`; AppleScript **Automation** permission for the terminal | Linux: swap `notify()` for `notify-send` and `user_is_looking()` for your WM's focus query (or always return false) |
| 7 — Persistent alerts | Module 6, `terminal-notifier` (**not** the `osascript` fallback), `plutil`, `lsregister` | macOS-only. Module 5 too, for view-triggered dismissal — see the module's note |
| 8 — Switcher | tmux ≥ 3.2 (`display-popup`), `fzf` | `tmux display-menu` (no fuzzy search, no preview) |
Author's baseline for reference: macOS 15.6, Ghostty 1.2.3 (quick-terminal/quake mode), tmux 3.6a,
fish as the interactive shell (irrelevant — every script is bash), Homebrew at `/opt/homebrew`.
Substitute freely; the only environment values that must match reality are `TERMINAL_APP` and any
absolute tool paths.
---
## The implementation
Create `~/.claude/hooks/` and `~/.claude/status/` if absent, and `chmod +x` every script.
Paths use `$HOME` throughout — every one of these runs through a shell (tmux `#(...)`,
`run-shell`, `display-popup -E`, Claude Code hook commands), so it expands. If a path fails to
resolve, substitute an absolute home directory and re-test.
### Module 1 — State tracking via Claude Code hooks (core)
On each Claude lifecycle event, write `~/.claude/status/<session-id>` = `state|epoch|pane|cwd`,
where state is `running` / `permission` / `done`; `clear` deletes it. Files: `claude-status.sh`
(below, shared with Modules 6–7) + the hooks block in `~/.claude/settings.json`.
### Module 2 — Process discovery (core)
Prints the tmux `pane_id` of every live `claude` process by matching process TTY to pane TTY.
**This is what makes idle instances appear at all** — a purely event-driven design misses them,
and shows a stuck "working" indicator after an ESC interrupt.
`~/.claude/hooks/claude-live-panes.sh`:
```bash
#!/usr/bin/env bash
# Outputs the tmux pane_id (one per line) of every live Claude Code CLI instance,
# by matching each `claude` process's TTY to a tmux pane's TTY. Authoritative and
# self-correcting: finds idle instances and ones with no state file.
# TTYs of live `claude` CLI processes. `$1 != "??"` drops the tty-less
# chrome-native-host helper; the basename check keeps only the real CLI.
ttys="$(ps -Ao tty,command 2>/dev/null | awk '
$1 != "??" {
n = split($2, p, "/")
if (p[n] == "claude") print $1
}
' | sort -u)"
[ -n "$ttys" ] || exit 0
# Emit the pane whose tty matches each claude tty (pane_tty is /dev/ttysNNN).
tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null | while read -r ptty pid; do
printf '%s\n' "$ttys" | grep -qxF "${ptty#/dev/}" && printf '%s\n' "$pid"
done
```
### Module 3 — Status-line renderer (core)
For each discovered pane: footer says working → `●`; otherwise overlay the newest state file for
that pane. Three signals, each used for what it's best at — **which instances exist** comes from
process discovery, **is it working right now** from the pane's own footer (never stuck, unlike an
event flag after an interrupt), **everything else** from hook state files.
`~/.claude/hooks/claude-tmux-status.sh`:
```bash
#!/usr/bin/env bash
# Prints the one-line Claude status summary for tmux status-right.
# 1. Which instances exist -> claude-live-panes.sh (TTY -> pane).
# 2. Is it working right now -> the pane footer shows "esc to interrupt".
# 3. Other states -> hook state files ~/.claude/status/<sid>.
# Layout: ●N 👁M ✋<perm names> ✅<done names, cap then ✅+K> (counters lead).
# Written for macOS's stock bash 3.2.
dir="$HOME/.claude/status"
panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"
DONE_CAP=4
WORKING_MARK='esc to interrupt' # Claude's footer shows this only while processing
live_panes=" $(tmux list-panes -a -F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "
# Load state files into arrays; prune files whose pane is truly gone.
n=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r st ts pn cw < "$f"
sid="$(basename "$f")"
if [ -n "$pn" ]; then
case "$live_panes" in
*" $pn "*) : ;;
*) rm -f "$f" "$dir/.viewed-$sid" "$dir/.notify-$sid"; continue ;;
esac
fi
A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_SID[$n]="$sid"
n=$((n + 1))
done
running=0; viewed=0; done_count=0; perm_l=""; done_l=""
pane_working() { # 0 (true) if the pane's footer shows Claude is processing
tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"
}
classify_idle() { # state sid label -- for a pane that is NOT actively working
local state="$1" sid="$2" label="$3"
case "$state" in
permission) perm_l="$perm_l #[fg=colour208,bold]✋${label}#[default]" ;;
done)
if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then
viewed=$((viewed + 1))
else
done_count=$((done_count + 1))
[ "$done_count" -le "$DONE_CAP" ] && done_l="$done_l #[fg=colour46,bold]✅${label}#[default]"
fi ;;
*) viewed=$((viewed + 1)) ;; # idle / stuck-running / waiting / untracked
esac
}
for pane in $(bash "$panes_helper" 2>/dev/null); do
label="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)"
[ -n "$label" ] || label="claude"
if pane_working "$pane"; then
running=$((running + 1)); continue
fi
bi=-1; bts=-1; k=0
while [ "$k" -lt "$n" ]; do
if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi
k=$((k + 1))
done
if [ "$bi" -ge 0 ]; then classify_idle "${A_STATE[$bi]}" "${A_SID[$bi]}" "$label"
else classify_idle "idle" "" "$label"; fi
done
if [ "$done_count" -gt "$DONE_CAP" ]; then
done_l="$done_l #[fg=colour46,bold]✅+$((done_count - DONE_CAP))#[default]"
fi
out=""
[ "$running" -gt 0 ] && out="$out #[fg=colour39]●${running}#[default]"
[ "$viewed" -gt 0 ] && out="$out #[fg=colour244]👁${viewed}#[default]"
out="$out$perm_l$done_l"
if [ -n "$out" ]; then printf '%s' "${out# }"
else printf '#[fg=colour240]· claude idle#[default]'; fi
```
### Module 4 — tmux wiring (core)
See the `~/.tmux.conf` block at the end of this section; Module 4 is the first two lines.
### Module 5 — "Viewed" collapse (optional)
When you **focus** a finished instance's pane, drop a `.viewed-<id>` marker so `✅name` collapses
into the `👁` count — gated on real focus, so an instance finishing while your terminal is
backgrounded stays a visible `✅`. The same hook retracts that instance's alert (Module 7).
Skipping this: also drop the five `set-hook` lines from `~/.tmux.conf`, and Module 7 loses
view-triggered dismissal (it still retracts on `running`/`clear`).
`~/.claude/hooks/claude-mark-viewed.sh`:
```bash
#!/usr/bin/env bash
# Marks finished ('done') Claude sessions as "viewed" once you actually focus
# their pane, so they collapse from the named list into the 👁 counter.
#
# Invoked (backgrounded) by tmux focus/navigation hooks in ~/.tmux.conf:
# after-select-pane, after-select-window, client-session-changed,
# client-attached, client-focus-in.
# Using client-focus-in means "viewed" only counts when the terminal itself
# has OS focus -- a task finishing while the terminal is backgrounded is NOT hidden.
dir="$HOME/.claude/status"
[ -d "$dir" ] || exit 0
# Focused pane(s): the active pane of the active window of each attached session.
focused=" $(tmux list-panes -a \
-f '#{&&:#{pane_active},#{&&:#{window_active},#{session_attached}}}' \
-F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "
changed=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r state ts pane cwd < "$f"
[ -n "$pane" ] || continue
case "$focused" in *" $pane "*) ;; *) continue ;; esac
sid="$(basename "$f")"
[ "$state" = "done" ] && { marker="$dir/.viewed-$sid"; [ -f "$marker" ] || { touch "$marker"; changed=1; }; }
# --- Module 7: looking at the pane means you've seen it, so retract its macOS alert.
# Those are manual-dismiss and would otherwise pile up until clicked. .notify-<sid>
# doubles as "an alert is currently posted", so this fires once and only when there's
# one to pull down. Applies to `permission` too: the ✋ in the bar is the standing
# reminder. Delete this block if you skip Module 7.
if [ -f "$dir/.notify-$sid" ]; then
"$HOME/.claude/hooks/claude-notify-clear.sh" "$sid"
rm -f "$dir/.notify-$sid"
fi
done
# Only force a redraw when something actually changed.
[ "$changed" -eq 1 ] && tmux refresh-client -S 2>/dev/null
exit 0
```
### Module 6 — Desktop notifications (optional, macOS as written)
Notify on `permission`/`done` **only when the terminal isn't frontmost** (so you're never spammed
while watching), de-duplicated per session, silent. Lives inside `claude-status.sh` below.
### Module 7 — Persistent alerts that retract themselves (optional, macOS)
**The problem it solves:** a banner auto-fades after a few seconds. If you're away from the desk
when an agent finishes, you miss it entirely and the whole notification is wasted. macOS's
**Alerts** style stays on screen until dismissed — but alert style is a **per-app OS setting**,
not something a notification payload can request.
**Why a private app bundle:** macOS seeds an app's style from `NSUserNotificationAlertStyle` in
its `Info.plist`, but **only the first time that bundle id registers**. Homebrew's
`terminal-notifier` is long since registered as `banner`, so editing its plist does nothing — and
flipping its style by hand in System Settings would make _every_ script that uses it persistent.
So: a copy of the bundle with its own `CFBundleIdentifier`. It also makes notifications read as
**"Claude Code"** rather than "terminal-notifier".
**Why it auto-retracts:** persistence without retraction just moves the chore. Every alert is
tagged `-group claude-<session-id>`, which is a precise handle to pull that one back down, from
three places: you focused that instance's pane (Module 5), work resumed, or the session ended.
Only the _focused_ instance's alert is retracted — the others keep standing, which is the point.
`~/.claude/hooks/claude-notifier-install.sh` — run once (and again after
`brew upgrade terminal-notifier`):
```bash
#!/usr/bin/env bash
# (Re)builds ~/.claude/bin/ClaudeNotify.app -- a private copy of terminal-notifier.app used
# by claude-status.sh to post macOS notifications that DO NOT auto-dismiss.
#
# Why a copy: macOS alert style is per-app, and an app's *initial* style is seeded from
# NSUserNotificationAlertStyle in its Info.plist the first time it registers. Homebrew's
# terminal-notifier ships "banner" and is already registered, so the only way to get
# persistent Alerts without hijacking every other terminal-notifier caller is a bundle with
# its own CFBundleIdentifier. Bonus: notifications are attributed to "Claude Code".
#
# Run again after `brew upgrade terminal-notifier` to pick up the new binary. The bundle id
# stays the same, so the alert style already chosen in System Settings is preserved.
set -euo pipefail
# Any reverse-DNS id you have NEVER used before -- the unused-ness is what makes the style
# seeding work. Put your own name/domain in it. Don't change it later (see gotcha 10).
BUNDLE_ID="com.example.claude-notify"
BUNDLE_NAME="Claude Code"
DST="$HOME/.claude/bin/ClaudeNotify.app"
src_bin="$(command -v terminal-notifier || true)"
[ -n "$src_bin" ] || { echo "terminal-notifier not found -- brew install terminal-notifier" >&2; exit 1; }
# Locate terminal-notifier.app. Homebrew's bin/terminal-notifier is a two-line wrapper that
# execs the binary inside the bundle, so read the path straight out of it; other install
# layouts put the CLI inside the bundle itself, so fall back to resolving the symlink.
SRC="$(sed -n 's|.*"\(/.*\.app\)/Contents/MacOS/[^"]*".*|\1|p' "$src_bin" 2>/dev/null | head -1)"
if [ ! -d "${SRC:-}" ]; then
real="$(readlink -f "$src_bin" 2>/dev/null || python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$src_bin")"
case "$real" in */Contents/MacOS/*) SRC="${real%/Contents/MacOS/*}" ;; esac
fi
[ -d "${SRC:-}" ] || { echo "could not locate terminal-notifier.app (from $src_bin)" >&2; exit 1; }
mkdir -p "$(dirname "$DST")"
rm -rf "$DST"
cp -R "$SRC" "$DST"
plist="$DST/Contents/Info.plist"
plutil -replace CFBundleIdentifier -string "$BUNDLE_ID" "$plist"
plutil -replace CFBundleName -string "$BUNDLE_NAME" "$plist"
plutil -replace NSUserNotificationAlertStyle -string "alert" "$plist"
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$DST"
echo "built $DST ($BUNDLE_ID)"
echo "verify: System Settings > Notifications > \"$BUNDLE_NAME\" -> Alert style = Alerts"
"$DST/Contents/MacOS/terminal-notifier" -title "Claude Code" -message "Persistent alert test -- this should stay until dismissed" -group claude-install-test
```
`~/.claude/hooks/claude-notify-clear.sh`:
```bash
#!/usr/bin/env bash
# Pulls down the macOS alert(s) that claude-status.sh posted for the given session id(s).
# Usage: claude-notify-clear.sh <session-id> [session-id...]
#
# The alerts are manual-dismiss by design (see claude-notifier-install.sh), so something has
# to retract them once they're moot. Callers:
# * claude-mark-viewed.sh -- you focused that instance's pane, so you've seen it
# * claude-status.sh -- the session went `running` again, or ended (`clear`)
#
# NSUserNotificationCenter only manages its own app's delivered notifications, so the remove
# MUST go through the same bundle that posted -- hence the ClaudeNotify.app path first, with
# the same fallback order as claude-status.sh's notify().
set -u
bin="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"
[ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)"
[ -n "$bin" ] || exit 0
for sid in "$@"; do
[ -n "$sid" ] || continue
"$bin" -remove "claude-$sid" >/dev/null 2>&1 || true
done
exit 0
```
**One manual step, once:** after the installer's test notification appears, open
**System Settings → Notifications → "Claude Code"** and confirm **Alert style = Alerts**. The
`Info.plist` seeding normally sets it; the dropdown is the ground truth, and one click fixes it
permanently if it reads Banners.
### Modules 1 + 6 + 7 — `~/.claude/hooks/claude-status.sh`
The shared entry point. Module boundaries are marked; delete a marked block to skip that module.
```bash
#!/usr/bin/env bash
# Records this Claude session's status for the tmux indicator, and fires a
# macOS notification when the session goes idle while the terminal is not the
# frontmost app (closed or tabbed away from).
# Usage: claude-status.sh <running|permission|done|clear>
# Hook JSON (with .session_id and .cwd) is read from stdin.
state="${1:-}"
dir="$HOME/.claude/status"
mkdir -p "$dir"
input="$(cat)"
sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null)"
[ -n "$sid" ] || sid="unknown"
file="$dir/$sid"
notif_marker="$dir/.notify-$sid"
viewed_marker="$dir/.viewed-$sid"
cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"
# --- Module 7: an alert from the last idle may still be on screen (they're manual-dismiss).
# Work resuming or the session ending makes it moot, so retract it. Gated on the marker
# existing, since `running` fires on every tool call and must not spawn the notifier each
# time. Delete this block if you skip Module 7.
case "$state" in
running|clear)
[ -f "$notif_marker" ] && "$HOME/.claude/hooks/claude-notify-clear.sh" "$sid"
;;
esac
# --- write / clear state for the tmux status bar (Module 1) ---
if [ "$state" = "clear" ]; then
rm -f "$file" "$notif_marker" "$viewed_marker"
else
# state | epoch | tmux pane (empty when not in tmux) | cwd -- written atomically
# so the renderer / mark-viewed hook never see a half-written line.
printf '%s|%s|%s|%s\n' "$state" "$(date +%s)" "${TMUX_PANE:-}" "$cwd" > "$file.tmp" \
&& mv -f "$file.tmp" "$file"
fi
# New work resumes: reset the notify marker (so the next idle notifies again) and
# drop the viewed marker (a fresh cycle should re-show, not stay collapsed).
[ "$state" = "running" ] && rm -f "$notif_marker" "$viewed_marker"
# ============ Module 6 (+7): notify when idle and you're not looking ============
TERMINAL_APP="ghostty" # System Events process name of YOUR terminal (case-insensitive)
# Module 7's private bundle: same terminal-notifier binary, but its own CFBundleIdentifier
# and NSUserNotificationAlertStyle=alert, so what it posts stays on screen until dismissed.
# Falls back to plain terminal-notifier (banners) if Module 7 isn't installed.
NOTIFIER_BIN="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"
notify() {
local title="$1" msg="$2" bin="$NOTIFIER_BIN"
[ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)"
if [ -n "$bin" ]; then
# Plain, non-blocking, silent. -sender/-activate can hang; don't add them.
# For it to actually POP (not just land in Notification Center):
# 1. alert style is Banners or Alerts, not None (System Settings > Notifications)
# 2. "Allow notifications when mirroring or sharing the display" = ON
# -- macOS hides ALL notifications while the screen is mirrored/recorded otherwise.
# -group is also the handle claude-notify-clear.sh uses to pull the alert back down.
"$bin" -title "$title" -message "$msg" -group "claude-$sid" >/dev/null 2>&1 || true
else
local t="${title//\\/\\\\}"; t="${t//\"/\\\"}"
local m="${msg//\\/\\\\}"; m="${m//\"/\\\"}"
osascript -e "display notification \"$m\" with title \"$t\"" 2>/dev/null || true
fi
}
# Returns 0 when you ARE looking at the terminal (it's frontmost) -> suppress notify.
user_is_looking() {
local front
front="$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null)"
if [ -n "$front" ]; then
[ "$(printf '%s' "$front" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$TERMINAL_APP" | tr '[:upper:]' '[:lower:]')" ]
return
fi
# Frontmost unknown (e.g. Automation permission denied) -> fall back to tmux clients.
if [ -n "$TMUX" ]; then
local clients; clients="$(tmux list-clients 2>/dev/null | wc -l | tr -d ' ')"
[ "${clients:-0}" -gt 0 ]; return
fi
return 1
}
case "$state" in
done|permission)
# Notify only when you're NOT looking at the terminal, and dedupe on state change.
last="$(cat "$notif_marker" 2>/dev/null || true)"
if ! user_is_looking && [ "$state" != "$last" ]; then
proj="$(basename "$cwd" 2>/dev/null)"
[ -n "$proj" ] && [ "$proj" != "." ] || proj="claude"
if [ "$state" = "permission" ]; then
notify "Claude · needs approval" "✋ $proj needs your approval"
else
notify "Claude · done" "✅ $proj finished — your turn"
fi
printf '%s' "$state" > "$notif_marker"
fi
;;
esac
# ====================== end Modules 6/7 ======================
# Redraw the tmux status bar immediately (server-wide, best-effort).
tmux refresh-client -S 2>/dev/null || true
exit 0
```
### Module 8 — Jump-to switcher popup (optional)
`prefix + a` → fzf popup of every instance (state emoji, `session:window.pane`, project, age)
with a live preview of that pane; `Enter` jumps to it.
`~/.claude/hooks/claude-switcher.sh`:
```bash
#!/usr/bin/env bash
# fzf popup listing every live Claude instance; Enter jumps to its pane.
# Bound to `prefix + a` via display-popup.
dir="$HOME/.claude/status"
panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"
WORKING_MARK='esc to interrupt'
FZF=fzf
command -v fzf >/dev/null 2>&1 || FZF=/opt/homebrew/bin/fzf # fallback path; adjust if needed
now="$(date +%s)"
n=0
shopt -s nullglob
for f in "$dir"/*; do
IFS='|' read -r st ts pn cw < "$f"
A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_CWD[$n]="$cw"; A_SID[$n]="$(basename "$f")"
n=$((n + 1))
done
pane_working() { tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"; }
rows=""
for pane in $(bash "$panes_helper" 2>/dev/null); do
bi=-1; bts=-1; k=0
while [ "$k" -lt "$n" ]; do
if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi
k=$((k + 1))
done
if [ "$bi" -ge 0 ]; then st="${A_STATE[$bi]}"; ts="${A_TS[$bi]}"; cwd="${A_CWD[$bi]}"; sid="${A_SID[$bi]}"
else st="idle"; ts=0; cwd=""; sid=""; fi
if pane_working "$pane"; then
emoji="●"; status="working"
else
case "$st" in
permission) emoji="✋"; status="needs approval" ;;
done) if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then emoji="👁"; status="idle"; else emoji="✅"; status="done"; fi ;;
*) emoji="👁"; status="idle" ;;
esac
fi
loc="$(tmux display-message -p -t "$pane" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)"
[ -n "$loc" ] || loc="$pane"
if [ -n "$cwd" ]; then proj="$(basename "$cwd")"
else proj="$(basename "$(tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null)")"; fi
[ -n "$proj" ] && [ "$proj" != "." ] || proj="—"
if [ "$status" != "working" ] && [ "$ts" -gt 0 ]; then
age=$(( now - ts ))
if [ "$age" -lt 60 ]; then when="${age}s ago"
elif [ "$age" -lt 3600 ]; then when="$((age / 60))m ago"
else when="$((age / 3600))h ago"; fi
else when="$status"; fi
rows="$rows${pane}"$'\t'"$emoji $loc $proj $when"$'\n'
done
if [ -z "$rows" ]; then printf 'No Claude instances.\n'; sleep 1; exit 0; fi
sel="$(printf '%s' "$rows" | "$FZF" \
--ansi --delimiter='\t' --with-nth=2.. --prompt='claude ▸ ' \
--preview 'tmux capture-pane -pe -J -t {1}' --preview-window=right:60%)"
[ -n "$sel" ] || exit 0
pane="$(printf '%s' "$sel" | cut -f1)"
[ -n "$pane" ] || exit 0
sess="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)"
tmux switch-client -t "$sess" \; select-window -t "$pane" \; select-pane -t "$pane"
```
### `~/.claude/settings.json` — hooks block (merge into any existing file)
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh running"
}
]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh permission"
}
]
},
{
"matcher": "idle_prompt",
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh done"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh done"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.claude/hooks/claude-status.sh clear"
}
]
}
]
}
}
```
- **`PostToolUse → running` matters:** `PreToolUse` fires _before_ a permission prompt, so
`PostToolUse` is what clears the `permission` state after you approve.
- **The `Notification` matcher split** is the reliable way to distinguish a permission prompt from
an idle "your turn" — do **not** regex the notification message text. `matcher` goes on the
group object, exactly as shown.
### `~/.tmux.conf` — additions
```tmux
# --- Claude status indicator (Module 4) ---
set -g status-right "#($HOME/.claude/hooks/claude-tmux-status.sh)"
set -g status-right-length 120 # default 40 truncates once several sessions are open
# Module 5: collapse a finished instance into 👁 once you focus its pane (and retract its
# alert, Module 7). focus-events must be on for client-focus-in to fire.
set -g focus-events on
set-hook -g after-select-pane 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g after-select-window 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-session-changed 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-attached 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
set-hook -g client-focus-in 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'
# Module 8: popup switcher (pick a key that's free for you; verify prefix+a is unbound).
bind-key a display-popup -E -w 80% -h 60% "$HOME/.claude/hooks/claude-switcher.sh"
```
Reload with `tmux source-file ~/.tmux.conf`. Use plain `set-hook -g` (not `-ga`) so reloading
doesn't stack duplicate hooks; these hook names are unset by default and unused by common plugins.
---
## Install order
**Install all of it unless the user said otherwise.** Foundation first, so there's something
visible to demo at every step.
1. `mkdir -p ~/.claude/hooks ~/.claude/status` — and `chmod +x ~/.claude/hooks/*.sh` as you go.
2. **Modules 1–4 (core).** `claude-status.sh` (you can paste the full version now; Modules 6/7's
blocks are inert until their scripts exist), `claude-live-panes.sh`,
`claude-tmux-status.sh`, the settings.json hooks, the first two `~/.tmux.conf` lines.
Reload tmux, **start a fresh Claude session** (gotcha 2) — the bar should light up. _Demoable._
3. **Module 5** — `claude-mark-viewed.sh` + the five `set-hook` lines. _Demoable: `✅` → `👁`._
4. **Module 6** — set `TERMINAL_APP`; grant the terminal **Automation** permission for System
Events on first prompt; on macOS enable _Allow notifications when mirroring or sharing the
display_ (gotcha 5). _Demoable: tab away, wait for a task to finish._
5. **Module 7** — `brew install terminal-notifier`, add `claude-notify-clear.sh`, set `BUNDLE_ID`
in `claude-notifier-install.sh`, run it, then confirm the alert style in System Settings.
_Demoable: an alert that outlives a banner and vanishes when you look at its pane._
6. **Module 8** — `claude-switcher.sh` + the `bind-key`; confirm the key is free first.
---
## Verification quick-checks
| Run this | Expect |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `bash ~/.claude/hooks/claude-live-panes.sh` | one pane id per live Claude instance. Cross-check: `tmux list-panes -a -F '#{pane_id} #{session_name}'` |
| `~/.claude/hooks/claude-tmux-status.sh \| sed -E 's/#\[[^]]*\]//g'` | the rendered line, colours stripped, e.g. `●1 👁2` |
| Start a task in a Claude pane | `●` appears for it; on finish it becomes `✅<session>` |
| Trigger a tool needing approval (in a **fresh** session) | `✋<session>`; approve → `●` → `✅` |
| Focus a `✅` instance's pane | it folds into the `👁` count |
| `prefix + a` | popup lists every instance with a live preview; `Enter` jumps there |
| Tab away from the terminal, let a task finish | one notification, titled **Claude Code** |
| `~/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier -list ALL` | a row per still-posted alert, with group `claude-<session-id>` |
| Leave an alert for 30s without touching it | still on screen — that's Alerts style working (a banner would be long gone) |
| Now focus that instance's pane | alert disappears on its own; `-list ALL` no longer shows it |
| `defaults read ~/.claude/bin/ClaudeNotify.app/Contents/Info.plist NSUserNotificationAlertStyle` | `alert` |
**How the known failure modes present:**
- **No notification at all, everything else fine** → mirroring (gotcha 5) first, then Focus mode,
then alert style set to _None_ in System Settings.
- **Notification appears but fades in ~5s** → the bundle's style is Banners, not Alerts: check the
System Settings dropdown for "Claude Code" (gotcha 6). Confirm you're posting through
`ClaudeNotify.app`, not the Homebrew `terminal-notifier` on `PATH`.
- **Alerts appear but never retract** → `-remove` going through the wrong bundle (gotcha 7), or a
stale/missing `.notify-<id>` marker (`ls -a ~/.claude/status/`).
- **`●` never appears, other states fine** → the footer string changed (gotcha 1).
- **`✋`/`✅` never appear for one session** → it predates the hook config (gotcha 2).
---
## Optional extensions
- **Emojis/colours:** the glyphs and `#[fg=colourNNN]` codes in `claude-tmux-status.sh` /
`claude-switcher.sh` (tmux 256-colour palette). `DONE_CAP` controls how many `✅` names show
before collapsing to `✅+K`.
- **Name idle instances too:** move that case out of the `*)` count in `classify_idle`.
- **Click-to-jump alerts:** `terminal-notifier -execute '<command>'` runs a shell command when the
alert is clicked, so a click could switch tmux to the pane that raised it. Worth knowing if you
use a quake/dropdown terminal: `open -a <Terminal>` only activates the app and can't summon a
dropdown window — Ghostty 1.2.3 exposes a `QuickTerminalIntent` App Intent ("Open the Quick
Terminal… if already open, do nothing") reachable from a one-action Shortcut via
`shortcuts run "<name>"`; there is no CLI equivalent (`ghostty +new-window` is GTK/D-Bus,
Linux-only). Deliberately **not** installed here — auto-retract on view proved cleaner than
making the alert something you must click.
- **No tmux?** Modules 1, 6 and 7 work standalone; build a different front-end (menu-bar app,
notification-only setup) on top of the `~/.claude/status/` files.
A tmux status-bar indicator that summarises every live Claude Code CLI instance at a glance plus macOS
notifications that stay on screen until dismissed (and retract themselves once you look at the
instance they came from), and an fzf popup to jump straight to any instance’s pane. Built and
verified on macOS 15.6 + Ghostty + tmux 3.6a + stock bash 3.2 + Claude Code CLI; the core is
portable, the notification module is macOS-specific and called out as such.
Quick reference
The status bar (status-right), e.g. ●2 👁3 ✋games ✅notes:
Element
Meaning
●N (blue)
N instances actively processing right now
👁M (grey)
M instances idle / already seen
✋name (orange)
instance blocked waiting for your approval
✅name (green)
instance that just finished, not yet looked at
· claude idle
nothing running
Counters lead so they survive truncation. name is the tmux session name. ✅ collapses into
👁 once you focus that pane. More than 4 finished at once collapse to ✅+K.
Keys and commands
Action
How
Jump to any instance
prefix + a → fzf popup (live pane preview) → Enter
Dismiss one alert
Look at that instance’s pane — it retracts itself
Dismiss all alerts for a session
Send it new work (running retracts), or end the session
Force a status-bar redraw
tmux refresh-client -S
Rebuild the notifier app
~/.claude/hooks/claude-notifier-install.sh
The day-to-day loop: fire off work in several panes → tab away to a browser/editor → an alert
sits on screen per finished-or-blocked instance until you deal with it → open your terminal, look
at that pane, its alert disappears and its ✅ folds into 👁 → prefix + a to hop to the next.
Where things live
Path
What
~/.claude/hooks/*.sh
all scripts
~/.claude/status/<session-id>
state file, state|epoch|pane|cwd
~/.claude/status/.viewed-<id>
”you’ve looked at this” marker
~/.claude/status/.notify-<id>
”an alert is currently posted” marker
~/.claude/bin/ClaudeNotify.app
private notifier bundle (Module 7)
~/.claude/settings.json
hook wiring
~/.tmux.conf
status-right, focus hooks, switcher binding
What this is and why
People increasingly run several Claude Code CLI sessions at once — one per project, worktree,
or tmux pane. You lose track of them. Which are actively working? Which are blocked waiting for
you to approve a command? Which finished five minutes ago and are just sitting there? You end up
cycling through panes to check, and an agent that finished while you were in a browser can idle
for a long time before you notice.
This solves that with four things:
A tmux status-bar indicator summarising every live instance at a glance.
macOS alerts when an instance finishes or needs approval while you’re not looking at the
terminal — deliberately manual-dismiss, so they’re still there when you come back.
Automatic retraction of an alert once you’ve actually seen that instance, so persistence
never turns into a pile of stale alerts to clear by hand.
A popup switcher to jump straight to any instance’s pane.
The design point worth keeping: the status bar answers “what’s the state of everything?” at a
glance, and the alerts answer “do I need to come back to the terminal at all?” when you’re
elsewhere.
For the implementing agent (read this first)
Default: build this as specified — then check in about variations.
The reader most likely saw this demonstrated and wants the same thing. Reproduce it as
described. The defaults encode decisions arrived at by hitting the problems in Known gotchas —
treat them as load-bearing.
Do not open with “so which parts would you like?” — that dumps design work on someone who
just wants it working. Instead:
State the plan and confirm it — one question, phrased so “yes, all of it” is the easy
answer. For example, verbatim: “I’ll install the full setup: the tmux status indicator for
all your Claude instances, persistent macOS alerts that auto-dismiss once you view the
instance, and a prefix + a popup switcher. Eight modules, roughly 15 minutes, and I’ll
verify each one as I go. Sound good?”
Interview only where their environment forces a decision — not macOS (Module 6/7 are
macOS-only as written), not tmux, a different terminal app (TERMINAL_APP must match their
terminal’s process name), prefix + a already bound, an existing ~/.claude/settings.json
with a hooks key to merge into, no jq/fzf. These are compatibility questions, not
preference questions. Don’t silently substitute — ask, then adapt.
Call out the genuinely optional pieces, so opting out is cheap and informed: Module 5
(viewed-collapse), Module 6+7 (notifications), Module 8 (switcher). Modules 1–4 are the core
and only make sense together.
Then invite variations — and build them if asked. Don’t propose a redesign unprompted.
Deviate when the user asks or their environment requires it. Not because something looks
tidier — that’s how the gotchas get reintroduced.
The modules below are independently installable, so a user who wants only the status bar, or only
the notifications, can have exactly that — but that’s their call to make, not your opening move.
Known gotchas
Load-bearing — don’t simplify these away. Each cost real debugging time.
#
Symptom
Cause
Fix
Module
1
● never lights up
”Working” is detected by grepping the pane’s footer for esc to interrupt, a Claude TUI string, not a public API
Update WORKING_MARK at the top of claude-tmux-status.shandclaude-switcher.sh
3, 8
2
✋/✅ never appear for a session
Hook config only applies to newly started Claude sessions
Restart the session. Discovery still lists old ones as 👁 — that’s intended
1
3
Scripts fail with declare -A / substitution errors
macOS ships bash 3.2
Scripts avoid associative arrays and modern syntax on purpose; keep that if you edit them
all
4
Notifier call hangs the hook
terminal-notifier’s -sender / -activate flags can hang
Use only -title/-message/-group (and -remove) as shown
6, 7
5
Everything looks correctly configured but no notification ever pops
macOS suppresses all notifications — banners and alerts — while the display is mirrored or shared, silently routing them to Notification Center. Costs hours if you record screencasts
System Settings → Notifications → “Allow notifications when mirroring or sharing the display” = ON. Diagnose with system_profiler SPDisplaysDataType | grep -i mirror
6, 7
6
Editing Homebrew terminal-notifier.app’s Info.plist to alert changes nothing
macOS seeds an app’s alert style from NSUserNotificationAlertStyleonly on that bundle id’s first registration. An already-registered app ignores it forever
Use a copy with its own unused CFBundleIdentifier (Module 7). This is the whole reason that module exists — not cosmetics
7
7
-remove silently does nothing
NSUserNotificationCenter only manages its own app’s delivered notifications
The retract must run through the same bundle that posted — ClaudeNotify.app’s binary, not the Homebrew terminal-notifier on PATH
7
8
Notifier process spawns constantly / hooks feel sluggish
running fires on every tool call; an ungated retract launches a GUI binary each time
Gate every retract on .notify-<id> existing — it doubles as “an alert is currently posted”
7
9
Alerts pile up unread even after you’ve seen them
Manual-dismiss is the point, but nothing was retracting them
The retract hooks in Module 7 are not optional polish; without them persistence becomes a chore
7
10
Alert style silently reverts to Banners
Changing BUNDLE_ID later makes macOS treat it as a brand-new app, re-seeded and re-listed, with the old entry lingering in System Settings
Pick the id once and keep it. Re-running the installer with the same id is safe and preserves the style
7
11
Duplicated tmux hooks after every config reload
set-hook -gaappends
Use set-hook -g (plain) as shown
5
12
A Claude instance never appears
Discovery maps process TTY → tmux pane, so instances outside tmux are invisible, as are ones on another host or tmux server
Check tmux list-keys -T prefix | grep " a " and pick a free key
8
14
Editing the .app “breaks its signature”
Homebrew’s build is ad-hoc, linker-signed, Info.plist=not bound — editing Info.plist is safe and needs no re-sign
Confirm with codesign -dv <app>. If yours shows a real TeamIdentifier / sealed resources, re-sign after editing: codesign --force --sign - <app>
7
15
Status bar truncates
tmux status-right-length defaults to 40
Set it to ~120
4
Two smaller ones: state markers are dotfiles on purpose (.viewed-/.notify-) so the
"$dir"/* glob that walks state files skips them; and this is ~/.claude-global, monitoring
all your Claude sessions rather than one project.
Dependencies at a glance
Module
Hard dependencies
Alternatives / notes
1 — State tracking
Claude Code CLI, jq, bash
None; this is the foundation
2 — Process discovery
ps, awk, sort, grep, tmux
ps -Ao tty,command works on macOS + Linux; adjust flags for an unusual ps (e.g. -o tty,cmd)
3 — Renderer
Modules 1–2, tmux, awk, grep
—
4 — tmux wiring
tmux (any modern version)
—
5 — Viewed collapse
tmux with focus-events on, set-hook (tmux ≥ 3.0)
Skip it and finished instances stay ✅ until the session ends
6 — Notifications
macOS; terminal-notifieror built-in osascript; AppleScript Automation permission for the terminal
Linux: swap notify() for notify-send and user_is_looking() for your WM’s focus query (or always return false)
7 — Persistent alerts
Module 6, terminal-notifier (not the osascript fallback), plutil, lsregister
macOS-only. Module 5 too, for view-triggered dismissal — see the module’s note
8 — Switcher
tmux ≥ 3.2 (display-popup), fzf
tmux display-menu (no fuzzy search, no preview)
Author’s baseline for reference: macOS 15.6, Ghostty 1.2.3 (quick-terminal/quake mode), tmux 3.6a,
fish as the interactive shell (irrelevant — every script is bash), Homebrew at /opt/homebrew.
Substitute freely; the only environment values that must match reality are TERMINAL_APP and any
absolute tool paths.
The implementation
Create ~/.claude/hooks/ and ~/.claude/status/ if absent, and chmod +x every script.
Paths use $HOME throughout — every one of these runs through a shell (tmux #(...),
run-shell, display-popup -E, Claude Code hook commands), so it expands. If a path fails to
resolve, substitute an absolute home directory and re-test.
Module 1 — State tracking via Claude Code hooks (core)
On each Claude lifecycle event, write ~/.claude/status/<session-id> = state|epoch|pane|cwd,
where state is running / permission / done; clear deletes it. Files: claude-status.sh
(below, shared with Modules 6–7) + the hooks block in ~/.claude/settings.json.
Module 2 — Process discovery (core)
Prints the tmux pane_id of every live claude process by matching process TTY to pane TTY.
This is what makes idle instances appear at all — a purely event-driven design misses them,
and shows a stuck “working” indicator after an ESC interrupt.
~/.claude/hooks/claude-live-panes.sh:
#!/usr/bin/env bash# Outputs the tmux pane_id (one per line) of every live Claude Code CLI instance,# by matching each `claude` process's TTY to a tmux pane's TTY. Authoritative and# self-correcting: finds idle instances and ones with no state file.# TTYs of live `claude` CLI processes. `$1 != "??"` drops the tty-less# chrome-native-host helper; the basename check keeps only the real CLI.ttys="$(ps -Ao tty,command 2>/dev/null | awk ' $1 != "??" { n = split($2, p, "/") if (p[n] == "claude") print $1 }' | sort -u)"[ -n "$ttys" ] || exit 0# Emit the pane whose tty matches each claude tty (pane_tty is /dev/ttysNNN).tmux list-panes -a -F '#{pane_tty} #{pane_id}' 2>/dev/null | while read -r ptty pid; do printf '%s\n' "$ttys" | grep -qxF "${ptty#/dev/}" && printf '%s\n' "$pid"done
Module 3 — Status-line renderer (core)
For each discovered pane: footer says working → ●; otherwise overlay the newest state file for
that pane. Three signals, each used for what it’s best at — which instances exist comes from
process discovery, is it working right now from the pane’s own footer (never stuck, unlike an
event flag after an interrupt), everything else from hook state files.
~/.claude/hooks/claude-tmux-status.sh:
#!/usr/bin/env bash# Prints the one-line Claude status summary for tmux status-right.# 1. Which instances exist -> claude-live-panes.sh (TTY -> pane).# 2. Is it working right now -> the pane footer shows "esc to interrupt".# 3. Other states -> hook state files ~/.claude/status/<sid>.# Layout: ●N 👁M ✋<perm names> ✅<done names, cap then ✅+K> (counters lead).# Written for macOS's stock bash 3.2.dir="$HOME/.claude/status"panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"DONE_CAP=4WORKING_MARK='esc to interrupt' # Claude's footer shows this only while processinglive_panes=" $(tmux list-panes -a -F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "# Load state files into arrays; prune files whose pane is truly gone.n=0shopt -s nullglobfor f in "$dir"/*; do IFS='|' read -r st ts pn cw < "$f" sid="$(basename "$f")" if [ -n "$pn" ]; then case "$live_panes" in *" $pn "*) : ;; *) rm -f "$f" "$dir/.viewed-$sid" "$dir/.notify-$sid"; continue ;; esac fi A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_SID[$n]="$sid" n=$((n + 1))donerunning=0; viewed=0; done_count=0; perm_l=""; done_l=""pane_working() { # 0 (true) if the pane's footer shows Claude is processing tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"}classify_idle() { # state sid label -- for a pane that is NOT actively working local state="$1" sid="$2" label="$3" case "$state" in permission) perm_l="$perm_l #[fg=colour208,bold]✋${label}#[default]" ;; done) if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then viewed=$((viewed + 1)) else done_count=$((done_count + 1)) [ "$done_count" -le "$DONE_CAP" ] && done_l="$done_l #[fg=colour46,bold]✅${label}#[default]" fi ;; *) viewed=$((viewed + 1)) ;; # idle / stuck-running / waiting / untracked esac}for pane in $(bash "$panes_helper" 2>/dev/null); do label="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)" [ -n "$label" ] || label="claude" if pane_working "$pane"; then running=$((running + 1)); continue fi bi=-1; bts=-1; k=0 while [ "$k" -lt "$n" ]; do if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi k=$((k + 1)) done if [ "$bi" -ge 0 ]; then classify_idle "${A_STATE[$bi]}" "${A_SID[$bi]}" "$label" else classify_idle "idle" "" "$label"; fidoneif [ "$done_count" -gt "$DONE_CAP" ]; then done_l="$done_l #[fg=colour46,bold]✅+$((done_count - DONE_CAP))#[default]"fiout=""[ "$running" -gt 0 ] && out="$out #[fg=colour39]●${running}#[default]"[ "$viewed" -gt 0 ] && out="$out #[fg=colour244]👁${viewed}#[default]"out="$out$perm_l$done_l"if [ -n "$out" ]; then printf '%s' "${out# }"else printf '#[fg=colour240]· claude idle#[default]'; fi
Module 4 — tmux wiring (core)
See the ~/.tmux.conf block at the end of this section; Module 4 is the first two lines.
Module 5 — “Viewed” collapse (optional)
When you focus a finished instance’s pane, drop a .viewed-<id> marker so ✅name collapses
into the 👁 count — gated on real focus, so an instance finishing while your terminal is
backgrounded stays a visible ✅. The same hook retracts that instance’s alert (Module 7).
Skipping this: also drop the five set-hook lines from ~/.tmux.conf, and Module 7 loses
view-triggered dismissal (it still retracts on running/clear).
~/.claude/hooks/claude-mark-viewed.sh:
#!/usr/bin/env bash# Marks finished ('done') Claude sessions as "viewed" once you actually focus# their pane, so they collapse from the named list into the 👁 counter.## Invoked (backgrounded) by tmux focus/navigation hooks in ~/.tmux.conf:# after-select-pane, after-select-window, client-session-changed,# client-attached, client-focus-in.# Using client-focus-in means "viewed" only counts when the terminal itself# has OS focus -- a task finishing while the terminal is backgrounded is NOT hidden.dir="$HOME/.claude/status"[ -d "$dir" ] || exit 0# Focused pane(s): the active pane of the active window of each attached session.focused=" $(tmux list-panes -a \ -f '#{&&:#{pane_active},#{&&:#{window_active},#{session_attached}}}' \ -F '#{pane_id}' 2>/dev/null | tr '\n' ' ') "changed=0shopt -s nullglobfor f in "$dir"/*; do IFS='|' read -r state ts pane cwd < "$f" [ -n "$pane" ] || continue case "$focused" in *" $pane "*) ;; *) continue ;; esac sid="$(basename "$f")" [ "$state" = "done" ] && { marker="$dir/.viewed-$sid"; [ -f "$marker" ] || { touch "$marker"; changed=1; }; } # --- Module 7: looking at the pane means you've seen it, so retract its macOS alert. # Those are manual-dismiss and would otherwise pile up until clicked. .notify-<sid> # doubles as "an alert is currently posted", so this fires once and only when there's # one to pull down. Applies to `permission` too: the ✋ in the bar is the standing # reminder. Delete this block if you skip Module 7. if [ -f "$dir/.notify-$sid" ]; then "$HOME/.claude/hooks/claude-notify-clear.sh" "$sid" rm -f "$dir/.notify-$sid" fidone# Only force a redraw when something actually changed.[ "$changed" -eq 1 ] && tmux refresh-client -S 2>/dev/nullexit 0
Module 6 — Desktop notifications (optional, macOS as written)
Notify on permission/doneonly when the terminal isn’t frontmost (so you’re never spammed
while watching), de-duplicated per session, silent. Lives inside claude-status.sh below.
Module 7 — Persistent alerts that retract themselves (optional, macOS)
The problem it solves: a banner auto-fades after a few seconds. If you’re away from the desk
when an agent finishes, you miss it entirely and the whole notification is wasted. macOS’s
Alerts style stays on screen until dismissed — but alert style is a per-app OS setting,
not something a notification payload can request.
Why a private app bundle: macOS seeds an app’s style from NSUserNotificationAlertStyle in
its Info.plist, but only the first time that bundle id registers. Homebrew’s
terminal-notifier is long since registered as banner, so editing its plist does nothing — and
flipping its style by hand in System Settings would make every script that uses it persistent.
So: a copy of the bundle with its own CFBundleIdentifier. It also makes notifications read as
“Claude Code” rather than “terminal-notifier”.
Why it auto-retracts: persistence without retraction just moves the chore. Every alert is
tagged -group claude-<session-id>, which is a precise handle to pull that one back down, from
three places: you focused that instance’s pane (Module 5), work resumed, or the session ended.
Only the focused instance’s alert is retracted — the others keep standing, which is the point.
~/.claude/hooks/claude-notifier-install.sh — run once (and again after
brew upgrade terminal-notifier):
#!/usr/bin/env bash# (Re)builds ~/.claude/bin/ClaudeNotify.app -- a private copy of terminal-notifier.app used# by claude-status.sh to post macOS notifications that DO NOT auto-dismiss.## Why a copy: macOS alert style is per-app, and an app's *initial* style is seeded from# NSUserNotificationAlertStyle in its Info.plist the first time it registers. Homebrew's# terminal-notifier ships "banner" and is already registered, so the only way to get# persistent Alerts without hijacking every other terminal-notifier caller is a bundle with# its own CFBundleIdentifier. Bonus: notifications are attributed to "Claude Code".## Run again after `brew upgrade terminal-notifier` to pick up the new binary. The bundle id# stays the same, so the alert style already chosen in System Settings is preserved.set -euo pipefail# Any reverse-DNS id you have NEVER used before -- the unused-ness is what makes the style# seeding work. Put your own name/domain in it. Don't change it later (see gotcha 10).BUNDLE_ID="com.example.claude-notify"BUNDLE_NAME="Claude Code"DST="$HOME/.claude/bin/ClaudeNotify.app"src_bin="$(command -v terminal-notifier || true)"[ -n "$src_bin" ] || { echo "terminal-notifier not found -- brew install terminal-notifier" >&2; exit 1; }# Locate terminal-notifier.app. Homebrew's bin/terminal-notifier is a two-line wrapper that# execs the binary inside the bundle, so read the path straight out of it; other install# layouts put the CLI inside the bundle itself, so fall back to resolving the symlink.SRC="$(sed -n 's|.*"\(/.*\.app\)/Contents/MacOS/[^"]*".*|\1|p' "$src_bin" 2>/dev/null | head -1)"if [ ! -d "${SRC:-}" ]; then real="$(readlink -f "$src_bin" 2>/dev/null || python3 -c 'import os,sys;print(os.path.realpath(sys.argv[1]))' "$src_bin")" case "$real" in */Contents/MacOS/*) SRC="${real%/Contents/MacOS/*}" ;; esacfi[ -d "${SRC:-}" ] || { echo "could not locate terminal-notifier.app (from $src_bin)" >&2; exit 1; }mkdir -p "$(dirname "$DST")"rm -rf "$DST"cp -R "$SRC" "$DST"plist="$DST/Contents/Info.plist"plutil -replace CFBundleIdentifier -string "$BUNDLE_ID" "$plist"plutil -replace CFBundleName -string "$BUNDLE_NAME" "$plist"plutil -replace NSUserNotificationAlertStyle -string "alert" "$plist"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "$DST"echo "built $DST ($BUNDLE_ID)"echo "verify: System Settings > Notifications > \"$BUNDLE_NAME\" -> Alert style = Alerts""$DST/Contents/MacOS/terminal-notifier" -title "Claude Code" -message "Persistent alert test -- this should stay until dismissed" -group claude-install-test
~/.claude/hooks/claude-notify-clear.sh:
#!/usr/bin/env bash# Pulls down the macOS alert(s) that claude-status.sh posted for the given session id(s).# Usage: claude-notify-clear.sh <session-id> [session-id...]## The alerts are manual-dismiss by design (see claude-notifier-install.sh), so something has# to retract them once they're moot. Callers:# * claude-mark-viewed.sh -- you focused that instance's pane, so you've seen it# * claude-status.sh -- the session went `running` again, or ended (`clear`)## NSUserNotificationCenter only manages its own app's delivered notifications, so the remove# MUST go through the same bundle that posted -- hence the ClaudeNotify.app path first, with# the same fallback order as claude-status.sh's notify().set -ubin="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"[ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)"[ -n "$bin" ] || exit 0for sid in "$@"; do [ -n "$sid" ] || continue "$bin" -remove "claude-$sid" >/dev/null 2>&1 || truedoneexit 0
One manual step, once: after the installer’s test notification appears, open
System Settings → Notifications → “Claude Code” and confirm Alert style = Alerts. The
Info.plist seeding normally sets it; the dropdown is the ground truth, and one click fixes it
permanently if it reads Banners.
The shared entry point. Module boundaries are marked; delete a marked block to skip that module.
#!/usr/bin/env bash# Records this Claude session's status for the tmux indicator, and fires a# macOS notification when the session goes idle while the terminal is not the# frontmost app (closed or tabbed away from).# Usage: claude-status.sh <running|permission|done|clear># Hook JSON (with .session_id and .cwd) is read from stdin.state="${1:-}"dir="$HOME/.claude/status"mkdir -p "$dir"input="$(cat)"sid="$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null)"[ -n "$sid" ] || sid="unknown"file="$dir/$sid"notif_marker="$dir/.notify-$sid"viewed_marker="$dir/.viewed-$sid"cwd="$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)"# --- Module 7: an alert from the last idle may still be on screen (they're manual-dismiss).# Work resuming or the session ending makes it moot, so retract it. Gated on the marker# existing, since `running` fires on every tool call and must not spawn the notifier each# time. Delete this block if you skip Module 7.case "$state" in running|clear) [ -f "$notif_marker" ] && "$HOME/.claude/hooks/claude-notify-clear.sh" "$sid" ;;esac# --- write / clear state for the tmux status bar (Module 1) ---if [ "$state" = "clear" ]; then rm -f "$file" "$notif_marker" "$viewed_marker"else # state | epoch | tmux pane (empty when not in tmux) | cwd -- written atomically # so the renderer / mark-viewed hook never see a half-written line. printf '%s|%s|%s|%s\n' "$state" "$(date +%s)" "${TMUX_PANE:-}" "$cwd" > "$file.tmp" \ && mv -f "$file.tmp" "$file"fi# New work resumes: reset the notify marker (so the next idle notifies again) and# drop the viewed marker (a fresh cycle should re-show, not stay collapsed).[ "$state" = "running" ] && rm -f "$notif_marker" "$viewed_marker"# ============ Module 6 (+7): notify when idle and you're not looking ============TERMINAL_APP="ghostty" # System Events process name of YOUR terminal (case-insensitive)# Module 7's private bundle: same terminal-notifier binary, but its own CFBundleIdentifier# and NSUserNotificationAlertStyle=alert, so what it posts stays on screen until dismissed.# Falls back to plain terminal-notifier (banners) if Module 7 isn't installed.NOTIFIER_BIN="$HOME/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier"notify() { local title="$1" msg="$2" bin="$NOTIFIER_BIN" [ -x "$bin" ] || bin="$(command -v terminal-notifier 2>/dev/null || true)" if [ -n "$bin" ]; then # Plain, non-blocking, silent. -sender/-activate can hang; don't add them. # For it to actually POP (not just land in Notification Center): # 1. alert style is Banners or Alerts, not None (System Settings > Notifications) # 2. "Allow notifications when mirroring or sharing the display" = ON # -- macOS hides ALL notifications while the screen is mirrored/recorded otherwise. # -group is also the handle claude-notify-clear.sh uses to pull the alert back down. "$bin" -title "$title" -message "$msg" -group "claude-$sid" >/dev/null 2>&1 || true else local t="${title//\\/\\\\}"; t="${t//\"/\\\"}" local m="${msg//\\/\\\\}"; m="${m//\"/\\\"}" osascript -e "display notification \"$m\" with title \"$t\"" 2>/dev/null || true fi}# Returns 0 when you ARE looking at the terminal (it's frontmost) -> suppress notify.user_is_looking() { local front front="$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null)" if [ -n "$front" ]; then [ "$(printf '%s' "$front" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$TERMINAL_APP" | tr '[:upper:]' '[:lower:]')" ] return fi # Frontmost unknown (e.g. Automation permission denied) -> fall back to tmux clients. if [ -n "$TMUX" ]; then local clients; clients="$(tmux list-clients 2>/dev/null | wc -l | tr -d ' ')" [ "${clients:-0}" -gt 0 ]; return fi return 1}case "$state" in done|permission) # Notify only when you're NOT looking at the terminal, and dedupe on state change. last="$(cat "$notif_marker" 2>/dev/null || true)" if ! user_is_looking && [ "$state" != "$last" ]; then proj="$(basename "$cwd" 2>/dev/null)" [ -n "$proj" ] && [ "$proj" != "." ] || proj="claude" if [ "$state" = "permission" ]; then notify "Claude · needs approval" "✋ $proj needs your approval" else notify "Claude · done" "✅ $proj finished — your turn" fi printf '%s' "$state" > "$notif_marker" fi ;;esac# ====================== end Modules 6/7 ======================# Redraw the tmux status bar immediately (server-wide, best-effort).tmux refresh-client -S 2>/dev/null || trueexit 0
Module 8 — Jump-to switcher popup (optional)
prefix + a → fzf popup of every instance (state emoji, session:window.pane, project, age)
with a live preview of that pane; Enter jumps to it.
~/.claude/hooks/claude-switcher.sh:
#!/usr/bin/env bash# fzf popup listing every live Claude instance; Enter jumps to its pane.# Bound to `prefix + a` via display-popup.dir="$HOME/.claude/status"panes_helper="$HOME/.claude/hooks/claude-live-panes.sh"WORKING_MARK='esc to interrupt'FZF=fzfcommand -v fzf >/dev/null 2>&1 || FZF=/opt/homebrew/bin/fzf # fallback path; adjust if needednow="$(date +%s)"n=0shopt -s nullglobfor f in "$dir"/*; do IFS='|' read -r st ts pn cw < "$f" A_STATE[$n]="$st"; A_TS[$n]="${ts:-0}"; A_PANE[$n]="$pn"; A_CWD[$n]="$cw"; A_SID[$n]="$(basename "$f")" n=$((n + 1))donepane_working() { tmux capture-pane -p -t "$1" 2>/dev/null | awk 'NF{l=$0} END{print l}' | grep -q "$WORKING_MARK"; }rows=""for pane in $(bash "$panes_helper" 2>/dev/null); do bi=-1; bts=-1; k=0 while [ "$k" -lt "$n" ]; do if [ "${A_PANE[$k]}" = "$pane" ] && [ "${A_TS[$k]}" -gt "$bts" ]; then bts="${A_TS[$k]}"; bi="$k"; fi k=$((k + 1)) done if [ "$bi" -ge 0 ]; then st="${A_STATE[$bi]}"; ts="${A_TS[$bi]}"; cwd="${A_CWD[$bi]}"; sid="${A_SID[$bi]}" else st="idle"; ts=0; cwd=""; sid=""; fi if pane_working "$pane"; then emoji="●"; status="working" else case "$st" in permission) emoji="✋"; status="needs approval" ;; done) if [ -n "$sid" ] && [ -f "$dir/.viewed-$sid" ]; then emoji="👁"; status="idle"; else emoji="✅"; status="done"; fi ;; *) emoji="👁"; status="idle" ;; esac fi loc="$(tmux display-message -p -t "$pane" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)" [ -n "$loc" ] || loc="$pane" if [ -n "$cwd" ]; then proj="$(basename "$cwd")" else proj="$(basename "$(tmux display-message -p -t "$pane" '#{pane_current_path}' 2>/dev/null)")"; fi [ -n "$proj" ] && [ "$proj" != "." ] || proj="—" if [ "$status" != "working" ] && [ "$ts" -gt 0 ]; then age=$(( now - ts )) if [ "$age" -lt 60 ]; then when="${age}s ago" elif [ "$age" -lt 3600 ]; then when="$((age / 60))m ago" else when="$((age / 3600))h ago"; fi else when="$status"; fi rows="$rows${pane}"$'\t'"$emoji $loc $proj $when"$'\n'doneif [ -z "$rows" ]; then printf 'No Claude instances.\n'; sleep 1; exit 0; fisel="$(printf '%s' "$rows" | "$FZF" \ --ansi --delimiter='\t' --with-nth=2.. --prompt='claude ▸ ' \ --preview 'tmux capture-pane -pe -J -t {1}' --preview-window=right:60%)"[ -n "$sel" ] || exit 0pane="$(printf '%s' "$sel" | cut -f1)"[ -n "$pane" ] || exit 0sess="$(tmux display-message -p -t "$pane" '#{session_name}' 2>/dev/null)"tmux switch-client -t "$sess" \; select-window -t "$pane" \; select-pane -t "$pane"
~/.claude/settings.json — hooks block (merge into any existing file)
PostToolUse → running matters:PreToolUse fires before a permission prompt, so
PostToolUse is what clears the permission state after you approve.
The Notification matcher split is the reliable way to distinguish a permission prompt from
an idle “your turn” — do not regex the notification message text. matcher goes on the
group object, exactly as shown.
~/.tmux.conf — additions
# --- Claude status indicator (Module 4) ---set -g status-right "#($HOME/.claude/hooks/claude-tmux-status.sh)"set -g status-right-length 120 # default 40 truncates once several sessions are open# Module 5: collapse a finished instance into 👁 once you focus its pane (and retract its# alert, Module 7). focus-events must be on for client-focus-in to fire.set -g focus-events onset-hook -g after-select-pane 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'set-hook -g after-select-window 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'set-hook -g client-session-changed 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'set-hook -g client-attached 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'set-hook -g client-focus-in 'run-shell -b "$HOME/.claude/hooks/claude-mark-viewed.sh"'# Module 8: popup switcher (pick a key that's free for you; verify prefix+a is unbound).bind-key a display-popup -E -w 80% -h 60% "$HOME/.claude/hooks/claude-switcher.sh"
Reload with tmux source-file ~/.tmux.conf. Use plain set-hook -g (not -ga) so reloading
doesn’t stack duplicate hooks; these hook names are unset by default and unused by common plugins.
Install order
Install all of it unless the user said otherwise. Foundation first, so there’s something
visible to demo at every step.
mkdir -p ~/.claude/hooks ~/.claude/status — and chmod +x ~/.claude/hooks/*.sh as you go.
Modules 1–4 (core).claude-status.sh (you can paste the full version now; Modules 6/7’s
blocks are inert until their scripts exist), claude-live-panes.sh,
claude-tmux-status.sh, the settings.json hooks, the first two ~/.tmux.conf lines.
Reload tmux, start a fresh Claude session (gotcha 2) — the bar should light up. Demoable.
Module 5 — claude-mark-viewed.sh + the five set-hook lines. Demoable: ✅ → 👁.
Module 6 — set TERMINAL_APP; grant the terminal Automation permission for System
Events on first prompt; on macOS enable Allow notifications when mirroring or sharing the
display (gotcha 5). Demoable: tab away, wait for a task to finish.
Module 7 — brew install terminal-notifier, add claude-notify-clear.sh, set BUNDLE_ID
in claude-notifier-install.sh, run it, then confirm the alert style in System Settings.
Demoable: an alert that outlives a banner and vanishes when you look at its pane.
Module 8 — claude-switcher.sh + the bind-key; confirm the key is free first.
Verification quick-checks
Run this
Expect
bash ~/.claude/hooks/claude-live-panes.sh
one pane id per live Claude instance. Cross-check: tmux list-panes -a -F '#{pane_id} #{session_name}'
~/.claude/hooks/claude-tmux-status.sh | sed -E 's/#\[[^]]*\]//g'
the rendered line, colours stripped, e.g. ●1 👁2
Start a task in a Claude pane
● appears for it; on finish it becomes ✅<session>
Trigger a tool needing approval (in a fresh session)
✋<session>; approve → ● → ✅
Focus a ✅ instance’s pane
it folds into the 👁 count
prefix + a
popup lists every instance with a live preview; Enter jumps there
Tab away from the terminal, let a task finish
one notification, titled Claude Code
~/.claude/bin/ClaudeNotify.app/Contents/MacOS/terminal-notifier -list ALL
a row per still-posted alert, with group claude-<session-id>
Leave an alert for 30s without touching it
still on screen — that’s Alerts style working (a banner would be long gone)
Now focus that instance’s pane
alert disappears on its own; -list ALL no longer shows it
No notification at all, everything else fine → mirroring (gotcha 5) first, then Focus mode,
then alert style set to None in System Settings.
Notification appears but fades in ~5s → the bundle’s style is Banners, not Alerts: check the
System Settings dropdown for “Claude Code” (gotcha 6). Confirm you’re posting through
ClaudeNotify.app, not the Homebrew terminal-notifier on PATH.
Alerts appear but never retract → -remove going through the wrong bundle (gotcha 7), or a
stale/missing .notify-<id> marker (ls -a ~/.claude/status/).
● never appears, other states fine → the footer string changed (gotcha 1).
✋/✅ never appear for one session → it predates the hook config (gotcha 2).
Optional extensions
Emojis/colours: the glyphs and #[fg=colourNNN] codes in claude-tmux-status.sh /
claude-switcher.sh (tmux 256-colour palette). DONE_CAP controls how many ✅ names show
before collapsing to ✅+K.
Name idle instances too: move that case out of the *) count in classify_idle.
Click-to-jump alerts:terminal-notifier -execute '<command>' runs a shell command when the
alert is clicked, so a click could switch tmux to the pane that raised it. Worth knowing if you
use a quake/dropdown terminal: open -a <Terminal> only activates the app and can’t summon a
dropdown window — Ghostty 1.2.3 exposes a QuickTerminalIntent App Intent (“Open the Quick
Terminal… if already open, do nothing”) reachable from a one-action Shortcut via
shortcuts run "<name>"; there is no CLI equivalent (ghostty +new-window is GTK/D-Bus,
Linux-only). Deliberately not installed here — auto-retract on view proved cleaner than
making the alert something you must click.
No tmux? Modules 1, 6 and 7 work standalone; build a different front-end (menu-bar app,
notification-only setup) on top of the ~/.claude/status/ files.