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.
Rows also show the session's permission mode when it isn't the default β
Β· plan, Β· auto-edit, Β· bypass, etc. The hook records the
permission_mode field Claude Code sends with most events; the tooltip
always shows the exact value. Toggling modes while idle only becomes
visible on the next hook event (prompt, tool call, or stop).
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)