Observability for AI-native development workflows. Makes Claude Code's state glanceable in iTerm2 — and tells you exactly how close you are to hitting your usage limits before Anthropic throttles you.
Twenty tabs deep. You hear the Dock bounce. You glance at the tab bar. One tab is orange. That's Claude Code asking you a question. You switch over, answer it, watch it turn blue again, move on.
Six Claude Code hooks, a persistent iTerm2 daemon, a launchd watchdog, a tiny notifier .app for macOS notifications, a live status bar that surfaces session and weekly quota consumption in real time, and eight companion skills you can run from any session. macOS and iTerm2 only. One-line install.
| Color | State | What it means |
|---|---|---|
| white | idle | session is open, nothing in flight |
| blue | working | Claude is processing your prompt or running a tool |
| orange | waiting | Claude needs you (and the Dock icon bounces for good measure) |
The three transitions you actually care about all happen within ~50 ms of the underlying Claude Code event. The color is already right by the time your eye gets to the tab.
Plus:
- A badge in the top-right of every iTerm2 pane showing your project name. Stays readable when you're screen-recording or pair-programming and the tab bar is off screen.
- A tab title of
Claude · <project>, pinned so Claude Code's TUI can't overwrite it mid-render. - A live status bar at the bottom of every Claude Code session (details below).
- A kill switch (
touch ~/.claude/hooks/.disabled) and per-event debug logging (touch ~/.claude/hooks/.debug) for when something goes sideways.
👤 you@example.com claude-sonnet-4-6 38% Session: 1.5M/17M ~9% API cost ~$77 Week: 28M/140M ~20% API cost ~$4k Cost: ~$1.33 est ⎇ main
| Field | What it shows |
|---|---|
👤 you@example.com |
Claude account email from ~/.claude.json. Updates automatically on account switch. |
claude-sonnet-4-6 |
Active model. |
38% |
Context window usage. Green below 70%, yellow at 70%+ with a progress bar, red at 90%+. This is per-conversation context capacity. |
Context: ▓▓▓░░░░░░░ 38% ⚠ |
Full bar with label appears at 70%+, replacing the plain percentage. |
Session: 1.5M/17M ~9% |
Output tokens used in the current 6-hour rate-limit block vs the approximate block limit. Reverse-engineered from Claude Code's own /status dialog. |
API cost ~$77 |
What that session window's tokens would cost at Anthropic API rates. Marked ~ because it is an estimate. |
Week: 28M/140M ~20% |
Output tokens used since Monday 5pm ET vs the approximate weekly limit. This is the number that tells you if Anthropic is about to throttle you or switch you to per-token billing. |
API cost ~$4k |
API-equivalent value of the week's tokens. On Claude Max this reflects the compute you're extracting from your subscription, not what you pay. |
Cost: ~$1.33 est |
Total cost of the current conversation at API rates. Tilde and "est" for Max users; bare $1.33 for API key users. |
⎇ main |
Current git branch. Omitted outside git repos. |
Context window escalating notifications. Each threshold fires once per session and resets after /compact drops usage below 85%:
| Threshold | Notification |
|---|---|
| 90% | Run /compact to avoid losing context |
| 95% | Compact soon — context almost full |
| 97% | Compact immediately — context nearly gone |
| 99% | Compact NOW — you are about to lose context |
Session and week thresholds are configurable in headsup-status.conf:
HEADSUP_SESSION_LIMIT=17000000 # output tokens per 6h block
HEADSUP_WEEK_LIMIT=140000000 # output tokens per weekThe limits are approximate, derived by reverse-engineering the percentages Claude Code's built-in /status dialog displays against local JSONL session data. They match in practice. The built-in /status dialog calls its own figures "approximate" too.
headsup/
├── README.md
├── setup.sh # one-shot installer (idempotent, safe to re-run)
├── hooks/
│ ├── headsup-status.sh # bash entry point — Claude Code hooks call this
│ ├── headsup-status.conf # color + badge defaults (edit to customize)
│ ├── headsup-context-bar.sh # statusLine hook: account, model, context, quotas, cost
│ ├── headsup-usage-windows.py # JSONL aggregator for session/week token counts and cost
│ ├── headsup-session-cost.py # per-session token aggregator for /headsup-status
│ ├── headsup-update.sh # pull latest from GitHub (/headsup-update)
│ ├── headsup-resync.sh # force-resync a drifted tab (/headsup-resync-tab)
│ ├── headsup-watchdog.sh # outermost safety net (launchd, every 30s)
│ ├── headsup-status-report.sh # health snapshot (/headsup-status)
│ ├── headsup-diagnose.sh # active end-to-end test (/headsup-diagnose)
│ ├── headsup-notify-waiting.sh # fires macOS notification when a tab waits too long
│ ├── headsup-notifications.sh # /headsup-notifications skill helper
│ ├── headsup-notifications.conf # notifier config (enabled / threshold / sound)
│ ├── iterm2-daemon.py # persistent daemon — holds the iTerm2 websocket
│ ├── iterm2-apply-once.py # one-shot fallback when the daemon is unavailable
│ └── iterm2-set-tab-color.py # ad-hoc testing helper
├── launchagents/
│ └── claude-code.headsup-watchdog.plist.template
├── notifier-app/
│ ├── headsup-notifier.swift # Swift CLI posting via UNUserNotificationCenter
│ ├── Info.plist.template
│ ├── AppIcon.icns
│ ├── icon-source.png
│ ├── build-notifier.sh
│ └── build-icon.sh
└── skills/
├── headsup-colors/ # /headsup-colors change idle/working/waiting colors
├── headsup-label/ # /headsup-label name this tab
├── headsup-resync-tab/ # /headsup-resync-tab fix a stuck tab
├── headsup-status/ # /headsup-status passive health snapshot
├── headsup-diagnose/ # /headsup-diagnose active end-to-end test
├── headsup-notifications/# /headsup-notifications toggle + threshold the wait notification
└── headsup-update/ # /headsup-update pull latest from GitHub
setup.sh pulls the latest from GitHub, copies hooks and skills into ~/.claude/, builds the notifier .app, installs the watchdog LaunchAgent, and wires events into ~/.claude/settings.json.
setup.sh checks all of these and tells you the exact brew install command for anything missing.
- macOS (the daemon uses iTerm2's Python API — no Linux or Windows support)
- iTerm2:
brew install --cask iterm2 - Claude Code
- Python 3.9+:
brew install python@3.12 - jq:
brew install jq - Xcode Command Line Tools:
xcode-select --install(providesswiftcfor the notifier app)
git clone https://github.com/wasulajr/headsup.git
cd headsup
./setup.shsetup.sh is idempotent and safe to re-run. Re-running it pulls the latest from GitHub first, then applies any new hooks or skills. Pass --no-pull to skip the pull.
Each run:
- Pulls the latest from GitHub (skipped with
--no-pull) - Verifies all prerequisites
- Enables iTerm2's Python API if not already enabled
- Creates a Python venv at
~/.claude/hooks/iterm2-venv/ - Copies hook scripts to
~/.claude/hooks/ - Compiles + installs the notifier
.appto~/Library/Application Support/headsup/ - Installs and loads the watchdog LaunchAgent
- Copies skill folders to
~/.claude/skills/ - Merges hook wiring into
~/.claude/settings.json
After the script finishes:
- If iTerm2's Python API was just enabled, restart iTerm2 for it to take effect.
- Open a new iTerm2 tab and run
claude. The first hook event triggers an iTerm2 permission dialog. Click "Always Allow". - The first time a notification fires, macOS will prompt for permission. Click Allow.
- Type a prompt. The tab turns blue while Claude works, orange when it stops to ask you something.
headsup-status.sh runs on every Claude Code hook event and decides what color the tab should be:
| Claude Code event | Tab color | Dock attention | Why |
|---|---|---|---|
SessionStart |
white | no | Fresh session |
UserPromptSubmit |
blue | no | You sent a prompt; Claude is working |
PreToolUse |
blue | no | Claude called a tool; still working |
PostToolUse |
blue | no | Tool returned; still working |
Notification |
orange | yes | Claude wants your attention |
Stop |
orange | yes | Claude finished its turn |
Two synthetic events the script derives itself:
| Synthetic event | Tab color | When |
|---|---|---|
PreToolUseWaitForUser |
orange | PreToolUse payload contains AskUserQuestion. Front-runs Notification by several seconds. |
PostToolUseFromWait |
blue | Matching PostToolUse after an AskUserQuestion resolves |
A one-shot Python invocation per event took ~440 ms (cold-start for the iterm2 package plus websocket handshake). The system splits that into two processes:
headsup-status.sh(bash). Fires on every hook event. Writes<color> <attention>to a state file athooks/.state/<session-uuid>.state, then ensures the daemon is alive via PID file.iterm2-daemon.py(Python, long-running). Holds one open websocket to iTerm2's API for the whole machine. Polls state files every 30 ms and injects color changes directly into iTerm2, bypassing the pty. The Claude Code TUI's concurrent writes cannot tear the bytes.
The daemon's websocket can die quietly. Four layers catch it:
- Active liveness probe. Before every heartbeat write, the daemon calls
app.async_refresh. Failure triggers aDEADheartbeat and a clean exit; the bash hook respawns it on the next event. - Apply-error classification. Websocket exceptions during
apply_statetrigger the same DEAD-exit. Non-websocket errors just log. - Per-daemon reconciliation sweep. Every ~7s the daemon re-asserts desired state for every known session. Catches drift from any cause.
- launchd watchdog.
headsup-watchdog.shruns every 30s, completely independent of Claude Code. On the healthy path it costs a fewstatcalls. When the daemon is missing orDEAD, the watchdog respawns it. This layer works even when all Claude Code hook events have dried up entirely (hung process, network stall, whatever).
Claude Code sometimes fires a stray PostToolUse 1-8 seconds after Notification or Stop at end of turn. Without handling this, the tab flickers from orange to blue and back to orange, making the wait state appear to take 5-15 seconds longer than it actually did.
The bash hook maintains a per-session in-flight-tool counter (<uuid>.precount) and a waiting marker (<uuid>.waiting). PostToolUse suppresses itself only when the waiting marker is set AND the in-flight counter is 0. That combination uniquely identifies a stray end-of-turn delivery without affecting the legitimate "Notification mid-tool" case (e.g., a tool permission prompt where the counter is still positive).
headsup-usage-windows.py scans ~/.claude/projects/**/*.jsonl on every status bar update, aggregating output tokens (the primary compute signal) across two time windows:
- Session block: the current 6-hour block, resetting at 01:40, 07:40, 13:40, and 19:40 UTC
- Week: from Monday 17:00 ET (21:00 UTC) to the following Monday
The percentage calculation was validated against Claude Code's built-in /status dialog. The limits are reverse-engineered from that dialog's "Current session" and "Current week" percentages, which Claude Code computes from the same local JSONL data:
session limit: ~17M output tokens per 6h block
week limit: ~140M output tokens per week
Results are cached for 60 seconds so the hook stays fast on every tool call. The cache is invalidated automatically on the next status bar render after expiry.
The cost figures are API-equivalent: each JSONL entry's token counts are weighted by the model's published API pricing (per-model table in headsup-usage-windows.py). For Claude Max users this reflects the compute value extracted from the subscription, not the charge.
The context window percentage the status bar displays uses the API-provided used_percentage when present. When that field is absent, it falls back to a manual calculation that includes input tokens, cache creation tokens, cache read tokens, AND output tokens — all four token types count against the context window. This matches what Claude Code's own toolbar shows and fixes a common under-reporting bug in earlier implementations that excluded output tokens.
Wait notifications need the headsup icon, not the shell process icon. Since macOS Big Sur the only reliable way to attribute a notification to a specific icon is to post it from inside that bundle. headsup-notifier.swift is a small Cocoa app that calls UNUserNotificationCenter.add() from within NSApplication.shared, with activation policy .accessory (no Dock icon, no command-tab entry). setup.sh compiles it, drops it into a .app bundle, ad-hoc codesigns it, and registers it with Launch Services.
To swap the icon: drop a 1024x1024 PNG at notifier-app/icon-source.png, run ./notifier-app/build-icon.sh, then re-run ./setup.sh.
- Log rotation. When
headsup-status.logexceeds 5 MB, the bash hook moves it toheadsup-status.log.1on the next invocation. - Stale state-file GC. Every 5 minutes the daemon sweeps
.statefiles whose UUID is no longer a live iTerm2 session AND whose mtime is over 24 hours old.
Run /headsup-colors, say which state (idle / working / waiting) and what hex color. The skill writes to headsup-status.conf, applies the color to your current tab instantly, and commits + pushes to your fork.
Defaults:
IDLE_COLOR="ffffff" # white
PROCESS_COLOR="3a82f5" # blue
WAIT_COLOR="e67e22" # orangePer-project color overrides are also supported via functions in headsup-status.conf:
headsup_project_process_color() {
case "$PWD" in
*/api-server*) printf '8a3ffc' ;; # purple for the API project
*/frontend*) printf 'ff6b6b' ;; # coral for frontend
*) printf '' ;; # empty = use global default
esac
}Override the default badge (basename of $PWD) for this iTerm2 pane only. Useful when three tabs are all working inside the same repo and you can't tell "deploy debugging" from "frontend refactor" from "prod incident" in the tab bar. Local-only, keyed to ITERM_SESSION_ID. Re-run after iTerm2 restart.
Force-writes the correct state through all three independent delivery tiers in parallel (state file, one-shot Python API call, OSC to tty). Useful when a dropped event leaves a tab stuck on the wrong color.
Read-only. Shows daemon PID and heartbeat, watchdog LaunchAgent state, every session's current color and in-flight tool count, stray PostToolUse suppression count, and token usage for the current session's JSONL file.
Flashes white, blue, and orange on the current tab over ~3 seconds, verifying each color reaches the state file AND shows a "daemon applied" log line within 2 seconds. Restores the original color on exit. Exit code 0 on all-pass, 1 on any fail.
/headsup-diagnose --restart also kills the daemon and waits up to 35 seconds for the launchd watchdog to respawn it.
Fires a notification when a tab has been orange (waiting) for longer than a configurable threshold. Works even when you're away from the computer.
/headsup-notifications # show current state
/headsup-notifications on # enable
/headsup-notifications off # disable
/headsup-notifications 10 # set threshold to 10 minutes
/headsup-notifications test # fire a test notification right now
/headsup-notifications sound Ping # change the alert soundDefaults: enabled, 5-minute threshold, Glass sound. Multiple waiting tabs each get their own notification.
/headsup-updatePulls, shows a changelog, and restarts the daemon if iterm2-daemon.py changed.
Edit ~/.claude/hooks/headsup-status.conf. The file is sourced by the bash hook on every event, so changes take effect immediately (no restart needed):
# Tab colors (6-char hex, no leading #)
IDLE_COLOR="ffffff"
PROCESS_COLOR="3a82f5"
WAIT_COLOR="e67e22"
# Context window thresholds
WARN_AT=70 # yellow bar starts here
DANGER_AT=90 # red bar + notifications start here
# Quota limits (output tokens)
HEADSUP_SESSION_LIMIT=17000000
HEADSUP_WEEK_LIMIT=140000000
# Wait notification
NOTIFICATION_SOUND="Glass"Run /headsup-label in any Claude Code session and type the name you want. The badge and tab title update immediately. Stored in ~/.claude/hooks/headsup-status.d/<session-key>.conf, which is gitignored.
touch ~/.claude/hooks/.disabledBoth the bash hook and the launchd watchdog check for this file and no-op if present. Remove it to re-enable. To kill an already-running daemon immediately: pkill -f iterm2-daemon.py.
/headsup-status shows whether each layer is healthy (passive, read-only). /headsup-diagnose actively exercises the stack and reports pass/fail per step. These two commands answer most questions faster than reading further.
- Run
/headsup-diagnose. Step 5-7 failure means the daemon's API connection is dead. Check~/.claude/hooks/.state/daemon.stderr. - Confirm
pgrep -f iterm2-daemon.pyreturns a PID. If not, trigger any Claude Code event and re-check (the bash hook spawns it on demand). - Confirm the venv:
ls ~/.claude/hooks/iterm2-venv/bin/python. - Enable debug logging:
touch ~/.claude/hooks/.debug. Tail~/.claude/hooks/headsup-status.logwhile triggering events. - Confirm iTerm2's Python API is enabled: Settings, General, Magic, "Python API." The first connection requires manual approval in a dialog.
- Confirm
statusLineis wired in~/.claude/settings.json(see the settings block in Manual install below). - Restart Claude Code after changing
statusLine. - Test directly:
echo '{}' | bash ~/.claude/hooks/headsup-context-bar.sh. Should produce output.
Run /headsup-resync-tab. Sends RequestAttention=no paired with the correct color across all three delivery tiers.
Most likely the notifier .app wasn't built. Re-run setup.sh and watch the Step 6 output. If swiftc is missing: xcode-select --install. If macOS silently denied notification permission before the icon was in place, go to System Settings, Notifications, find headsup, and flip it to Allow. If headsup isn't listed, change the bundle ID in notifier-app/Info.plist.template and re-run setup.sh — macOS treats the new ID as a fresh app and re-prompts.
To add a new tab state (e.g., red on destructive tool use):
- Add the state-detection branch to
headsup-status.sh. - Call
set_tab_color "<hex>". The daemon picks it up from the state file; no daemon changes needed.
For new status bar segments, edit the LINE= assembly at the bottom of headsup-context-bar.sh.
For new badge or title logic, edit headsup_badge_text() and headsup_title_text() in headsup-status.conf. Both are bash functions.
If setup.sh doesn't fit your workflow:
iTerm2, Settings, General, Magic, enable "Python API."
python3 -m venv ~/.claude/hooks/iterm2-venv
~/.claude/hooks/iterm2-venv/bin/pip install iterm2mkdir -p ~/.claude/hooks ~/.claude/skills
cp hooks/* ~/.claude/hooks/
cp -r skills/headsup-* ~/.claude/skills/
chmod +x ~/.claude/hooks/*.sh ~/.claude/hooks/*.py./notifier-app/build-notifier.sh "$HOME/Library/Application Support/headsup"sed "s|__HOME__|$HOME|g" \
launchagents/claude-code.headsup-watchdog.plist.template \
> ~/Library/LaunchAgents/claude-code.headsup-watchdog.plist
launchctl load ~/Library/LaunchAgents/claude-code.headsup-watchdog.plist{
"hooks": {
"SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" SessionStart" }] }],
"Notification": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" Notification" }] }],
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" Stop" }] }],
"UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" UserPromptSubmit" }] }],
"PreToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" PreToolUse" }] }],
"PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": "\"$HOME/.claude/hooks/headsup-status.sh\" PostToolUse" }] }]
},
"statusLine": {
"type": "command",
"command": "\"$HOME/.claude/hooks/headsup-context-bar.sh\""
}
}All six hook events are load-bearing. See Event to color mapping above for why each matters.
- Cross-platform. macOS + iTerm2 only. The daemon uses iTerm2's Python API; badge and title use iTerm2-proprietary OSC sequences.
- Supported software. Bugs and ideas welcome at https://github.com/wasulajr/headsup/issues.
MIT. See LICENSE.