Skip to content

wasulajr/headsup

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

headsup

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.

At a glance

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.

The status bar

👤 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 week

The 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.

What's in this repo

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.

Prerequisites

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 (provides swiftc for the notifier app)

Quick install

git clone https://github.com/wasulajr/headsup.git
cd headsup
./setup.sh

setup.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:

  1. Pulls the latest from GitHub (skipped with --no-pull)
  2. Verifies all prerequisites
  3. Enables iTerm2's Python API if not already enabled
  4. Creates a Python venv at ~/.claude/hooks/iterm2-venv/
  5. Copies hook scripts to ~/.claude/hooks/
  6. Compiles + installs the notifier .app to ~/Library/Application Support/headsup/
  7. Installs and loads the watchdog LaunchAgent
  8. Copies skill folders to ~/.claude/skills/
  9. 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.

How it works

Event to color mapping

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

Two-process architecture

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:

  1. headsup-status.sh (bash). Fires on every hook event. Writes <color> <attention> to a state file at hooks/.state/<session-uuid>.state, then ensures the daemon is alive via PID file.
  2. 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.

Self-healing layers

The daemon's websocket can die quietly. Four layers catch it:

  1. Active liveness probe. Before every heartbeat write, the daemon calls app.async_refresh. Failure triggers a DEAD heartbeat and a clean exit; the bash hook respawns it on the next event.
  2. Apply-error classification. Websocket exceptions during apply_state trigger the same DEAD-exit. Non-websocket errors just log.
  3. Per-daemon reconciliation sweep. Every ~7s the daemon re-asserts desired state for every known session. Catches drift from any cause.
  4. launchd watchdog. headsup-watchdog.sh runs every 30s, completely independent of Claude Code. On the healthy path it costs a few stat calls. When the daemon is missing or DEAD, the watchdog respawns it. This layer works even when all Claude Code hook events have dried up entirely (hung process, network stall, whatever).

End-of-turn PostToolUse suppression

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).

Session and week quota tracking

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.

Context window percentage accuracy

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.

Notifier .app

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.

Housekeeping

  • Log rotation. When headsup-status.log exceeds 5 MB, the bash hook moves it to headsup-status.log.1 on the next invocation.
  • Stale state-file GC. Every 5 minutes the daemon sweeps .state files whose UUID is no longer a live iTerm2 session AND whose mtime is over 24 hours old.

Eight skills you can run from any Claude Code session

/headsup-colors: paint your tabs

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"     # orange

Per-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
}

/headsup-label: name this tab

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.

/headsup-resync-tab: fix a stuck tab

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.

/headsup-status: passive health snapshot

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.

/headsup-diagnose: active end-to-end test

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.

/headsup-notifications: macOS notification when Claude waits too long

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 sound

Defaults: enabled, 5-minute threshold, Glass sound. Multiple waiting tabs each get their own notification.

/headsup-update: pull the latest from GitHub

/headsup-update

Pulls, shows a changelog, and restarts the daemon if iterm2-daemon.py changed.

Customization

Colors and thresholds

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"

Per-session label

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.

Disable without uninstalling

touch ~/.claude/hooks/.disabled

Both 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.

Troubleshooting

First: run /headsup-status and /headsup-diagnose

/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.

"Tab color isn't changing"

  1. Run /headsup-diagnose. Step 5-7 failure means the daemon's API connection is dead. Check ~/.claude/hooks/.state/daemon.stderr.
  2. Confirm pgrep -f iterm2-daemon.py returns a PID. If not, trigger any Claude Code event and re-check (the bash hook spawns it on demand).
  3. Confirm the venv: ls ~/.claude/hooks/iterm2-venv/bin/python.
  4. Enable debug logging: touch ~/.claude/hooks/.debug. Tail ~/.claude/hooks/headsup-status.log while triggering events.
  5. Confirm iTerm2's Python API is enabled: Settings, General, Magic, "Python API." The first connection requires manual approval in a dialog.

"Status bar isn't showing"

  1. Confirm statusLine is wired in ~/.claude/settings.json (see the settings block in Manual install below).
  2. Restart Claude Code after changing statusLine.
  3. Test directly: echo '{}' | bash ~/.claude/hooks/headsup-context-bar.sh. Should produce output.

"Tab stuck in attention mode"

Run /headsup-resync-tab. Sends RequestAttention=no paired with the correct color across all three delivery tiers.

"Notifications show the wrong icon"

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.

"I want to extend it"

To add a new tab state (e.g., red on destructive tool use):

  1. Add the state-detection branch to headsup-status.sh.
  2. 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.

Manual install

If setup.sh doesn't fit your workflow:

1. Enable iTerm2's Python API

iTerm2, Settings, General, Magic, enable "Python API."

2. Create the Python venv

python3 -m venv ~/.claude/hooks/iterm2-venv
~/.claude/hooks/iterm2-venv/bin/pip install iterm2

3. Copy hooks and skills

mkdir -p ~/.claude/hooks ~/.claude/skills
cp hooks/* ~/.claude/hooks/
cp -r skills/headsup-* ~/.claude/skills/
chmod +x ~/.claude/hooks/*.sh ~/.claude/hooks/*.py

4. Build the notifier .app

./notifier-app/build-notifier.sh "$HOME/Library/Application Support/headsup"

5. Install the watchdog LaunchAgent

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

6. Wire hooks and status bar in settings.json

{
  "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.

What this isn't

  • 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.

License

MIT. See LICENSE.

About

Glanceable Claude Code state in iTerm2 tabs — white=idle, blue=processing, orange=waiting. iTerm2 daemon + launchd watchdog + macOS notifications + six companion skills. macOS only.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors