A tiny macOS menu bar stoplight that shows what Claude Code is doing at a glance.
| Light | Meaning |
|---|---|
| βͺοΈ Off | Not running β no active session |
| π΄ Red | Waiting for your input β permission prompt or a question from Claude |
| π‘ Yellow | Running β tools / thinking |
| π’ Green | Awaiting your next task (done) |
Where it shows up
Three surfaces, each toggleable in Settings (open it from any menu):
- Menu bar icon (default on) β the light in the top-right status bar.
- Floating desktop window β a small always-on-top panel that mirrors the status and lists sessions. Lock it to any screen corner, or unlock it to drag it anywhere (its position is remembered).
- Dock icon β the app icon itself tints to the current state.
Enabling the dock icon switches the app to a regular (dock-present) app; otherwise it runs as a menu-bar/desktop agent with no dock presence.
The icon
By default it's the Claude Code pixel mascot, tinted to the state color.
Drop an image at ~/.claude/status-light/icon.png to use custom artwork
instead β the app draws the full-color artwork and adds a
small stoplight status dot in the corner, so the mark stays recognizable while
still showing state. Use Set custom icon⦠(in the menu) or Reveal icon
folder⦠(in Settings) to open that folder in Finder.
Subagents
When any session has subagents running, the icon gains a small blue count badge (blue on purpose β it's activity, not a stoplight state), and that session's row in the menu and floating window reads "Β· N agents".
Jumping to a session's terminal
Click the menu-bar light (or a row in the floating window) to list every active session, and click one to jump straight to its terminal β handy with several Claude Code instances open at once:
- iTerm2 and Terminal.app β focused by their exact tab (matched by tty).
- Ghostty and everything else β brought to the front (these terminals don't expose per-tab tty to AppleScript, so tab-level targeting isn't possible).
When multiple sessions are active, the single light shows the most urgent state: red (blocked on you) βΈ green (done, wants a task) βΈ yellow (busy). That last ranking is a setting you can flip.
How it works
Claude Code ββ(hooks)βββΆ status-hook.sh βββΆ ~/.claude/status-light/sessions/<id>.json
β
βΌ
ClaudeStatusLight.app (menu bar)
Claude Code fires hooks on lifecycle events. Each
event runs status-hook.sh, which records that session's current state β plus the
terminal it's running in ($TERM_PROGRAM and tty) so the app can focus it, and the
Claude Code process's PID β in a small JSON file. The menu bar app watches that
folder and shows the highest-priority state across all sessions, so multiple
concurrent sessions aggregate correctly.
The recorded PID keeps the list honest: if a session's process is gone (terminal
closed, crash β anything that skips SessionEnd), the app drops it immediately and
deletes its file. Headless sessions with no terminal (e.g. daemon-spawned background
agents) are real and still counted; the session list shows what each one is working
on β its task title, read from the transcript path the hook records (e.g.
mlb-props Β· Improve system win rate from 59%) β falling back to (bg) for
untitled ones like pre-warmed spares. Terminal identity is sticky: daemon
processes may fire events for an interactive session, and their missing tty
must not strip the session's recorded terminal.
Background shells (Bash commands run with run_in_background) fire no hook
events at all, so the app reads the process table instead: children of a
session's pid carrying the ~/.claude/shell-snapshots/ wrapper signature are
Claude-spawned work still running. An otherwise-idle session with a running
shell shows π‘ with the command inline (mlb-props Β· sh: uv run python β¦) β
Claude may be done talking, but the work it started isn't finished. Titled
agent rows keep the command in the tooltip only; the title already says what
they're doing.
Claude Code parks finished background agents instead of exiting them, so a headless session that's been idle for 2+ minutes dims to gray β still a live process (the row stays, dot stays green), just without the visual weight of sessions that matter right now.
| Hook event | State | Light |
|---|---|---|
SessionStart |
idle | π’ awaiting next task |
UserPromptSubmit, PreToolUse, PostToolUse |
working | π‘ running |
Notification |
attention | π΄ waiting for input |
Stop |
idle | π’ awaiting next task |
SubagentStart / SubagentStop |
agent-start / agent-stop | π΅ subagent count badge |
SessionEnd |
β | (session removed) |
Two classifications happen inside the hook rather than the table: the ~60s
"Claude is waiting for your input" reminder notification is kept π’ (it just
means "ready for your next prompt"), and a PreToolUse for AskUserQuestion
is upgraded to π΄ β Claude is blocked on your answer, but no permission
notification ever fires for it. Subagent starts and stops maintain one marker
file each next to the session's JSON; the app counts them for the badge.
Remote sessions
Sessions running on other Macs and in Claude Code cloud sandboxes
(claude.ai/code, desktop-app cloud sessions) can show up in the same light.
Remote rows are labeled with their origin (office-mini Β· mlb-props,
cloud Β· web-app), count toward the aggregate light like any local session,
and aren't clickable β there's no terminal on this machine to focus.
other Macs: hooks β files β claude-status-light --publish ββ
cloud repos: .claude/status-relay.sh ββ raw hook events βββββΌβββΆ Worker + DO βββΆ app (GET /sessions, 4s)
this Mac: hooks β files ββββββββββββββββββββββββββββββββββ (your Cloudflare account)
Everything meets at a tiny relay Worker (in relay/) deployed to your own
Cloudflare account. Setup, one leg at a time:
- Main Mac (the one showing the light): open Settings β Remote
sessions β Set up Cloudflare relayβ¦ β a browser window asks you to log
in to Cloudflare, then the app deploys the relay Worker and writes
~/.claude/status-light/relay.jsonitself. No node, npm, or wrangler needed. (CLI alternative:scripts/deploy-relay.sh, which does need npm.) - Each remote Mac: in Settings on the main Mac, click Pair another
machineβ¦ β it shows a one-liner like
scripts/install-publisher.sh --pair <url> <code>(code is single-use, expires in 10 minutes). Run that from a clone of this repo on the remote Mac: it fetches the relay config, writesrelay.jsonwith that Mac's own hostname, builds the same binary, and registers a launchd agent that runsclaude-status-light --publish. (Manual fallback: copyrelay.jsonover, edit"host", and runscripts/install-publisher.shbare.) To update an already-paired Mac, pull and re-run it bare β it rebuilds against the existingrelay.json; no new code needed. - Cloud sessions, per repo:
scripts/enable-cloud-hooks.sh <repo>commits.claude/status-relay.shand its hook entries into that repo (user-level settings never sync to cloud sandboxes). Then setSTATUS_LIGHT_RELAY_URLandSTATUS_LIGHT_RELAY_TOKENenv vars in the repo's Claude Code cloud environment config and allowlist the Worker's domain. The hook is inert everywhere except a cloud sandbox and carries no secrets.
Liveness: a host's rows drop ~60 s after its publisher goes quiet; cloud rows
fade 30 min after their last event. If the app can't reach the relay at all,
the menu shows a relay unreachable footer β so an empty list still tells you
whether it's "no remote work" or "no signal".
Privacy: the relay runs in your own Cloudflare account, behind a bearer token only your machines hold. It carries session states, working-directory names, and task titles β never code, prompts, or transcripts.
The in-app login uses the same OAuth flow (and public client ID) as
Cloudflare's own wrangler login β Cloudflare doesn't offer third-party
OAuth registration. If that ever stops working, scripts/deploy-relay.sh
is the supported fallback.
If your Cloudflare account has never used Workers, open the Workers & Pages tab in the dashboard once first β Cloudflare asks you to pick your account's workers.dev subdomain there, and the deploy needs it to exist.
Install
Requires macOS with Xcode or the Command Line Tools (xcode-select --install).
git clone <this repo> cd claude-status-light ./scripts/install.sh
The installer builds the app, assembles a proper Claude Status Light.app
bundle in ~/Applications (with a generated icon), installs the hook to
~/.claude/status-light/, merges the hooks into ~/.claude/settings.json (a
timestamped backup is written first), and registers a login item so it runs
now and at every login.
On first install macOS may ask permission to control System Events (to add the login item) β that's expected; approve it once. The app is built locally and unsigned, so it runs without a Gatekeeper prompt.
Open a fresh Claude Code session (or restart an open one) so the new hooks load.
Updating: after pulling new code, just re-run ./scripts/install.sh β it
rebuilds and replaces the installed app in place. (Same idea on a publisher
Mac: re-run scripts/install-publisher.sh, see below.)
Uninstall
Quits the app, removes the login item, strips the hooks back out of
settings.json, and deletes the app bundle and installed files.
Layout
Package.swift SwiftPM manifest
Sources/ClaudeStatusLight/
main.swift entry point (+ hidden --render-iconset mode)
AppDelegate.swift ties the surfaces together; polls + watches state
Model.swift LightState + SessionState
StateStore.swift reads/aggregates per-session state files
ShellScanner.swift finds Claude-spawned background shells
Settings.swift UserDefaults-backed preferences
StatusBarController.swift menu bar item
FloatingPanelController.swift always-on-top desktop window
SettingsWindowController.swift preferences window
IconRenderer.swift spark/mascot icon + .icns generation
TerminalFocuser.swift click-a-session β focus its terminal
Controls.swift small AppKit target/action helpers
RelayConfig.swift reads ~/.claude/status-light/relay.json
RemoteWire.swift relay wire format + SessionState conversions
RemoteStore.swift polls the relay, merges remote sessions
Publisher.swift --publish mode: mirrors local state upward
CloudflareOAuth.swift PKCE + wrangler's OAuth contract (pure)
CloudflareAuth.swift Keychain tokens, loopback callback, login
CloudflareAPI.swift REST request builders for the deploy
CloudflareDeploy.swift five-step deploy engine with progress
RelayWorkerDist.swift generated β bundled relay Worker JS
relay/ Cloudflare Worker + Durable Object relay
hooks/status-hook.sh records per-session state (no jq required)
hooks/status-relay.sh cloud-sandbox hook: POSTs events to the relay
scripts/install.sh build + bundle .app + login item + wire up
scripts/uninstall.sh tear down
scripts/merge_settings.py idempotent settings.json hook editor
scripts/deploy-relay.sh deploy the relay Worker, write relay.json
scripts/install-publisher.sh set up a remote Mac to publish sessions
scripts/enable-cloud-hooks.sh commit the relay hook into a repo for cloud
scripts/build-relay-dist.sh regenerate RelayWorkerDist.swift (dev-side)