A single dot in your macOS menu bar that tells you whether your CLI coding agents are
still working or waiting on you. Red means thinking. Green means free.
Install · The name · How it works · Things worth knowing
If you drive coding agents from the terminal, you end up with several sessions running at once across tabs, splits and tmux windows. There's no way to tell which of them is grinding away and which finished four minutes ago and is quietly waiting for you. So you cycle through panes to check, which is exactly the kind of interruption the agents were meant to remove.
Hotaru puts that answer in the corner of your screen. One glance, no pane-hopping.
The dot is the whole interface. Open it and you get a line per session with its state, how long it has been in that state, its project path and the model it's running.
| Dot | Meaning |
|---|---|
| Grey, hollow | No sessions running |
| Red | At least one agent is thinking |
| Green | Every session is free and waiting on you |
The badge disambiguates when you have several sessions. 1/3
means one of three is thinking; a bare 3
means all three are in the same state; a single session shows no number at all.
Hotaru (蛍) is Japanese for firefly.
It fits on three levels. A firefly is a small glowing dot that signals by light alone, which is precisely the interface here. A swarm of them maps onto a set of sessions, each blinking its own state. And fireflies are the classic image of a summer night in Japan, which suits the Kanagawa palette the whole thing is coloured with — the same greens and reds you'd use in a Neovim or terminal theme, so it sits with the rest of your setup instead of shouting over it.
No screen-scraping and no polling of the agents. Both CLIs expose a hook system, so Hotaru asks them directly.
cursor-agent ─┐
├─→ ~/.hotaru/report.py ─→ ~/.hotaru/sessions/*.json ─→ SwiftBar plugin ─→ ●
Claude Code ──┘
report.py
is registered as a hook on a handful of lifecycle events. On each one it writes a small JSON file describing that session. Submitting a prompt or starting a tool call marks it busy; the stop event marks it free; the session-end event deletes the file.
| Event | cursor-agent | Claude Code | Result |
|---|---|---|---|
| Session opens | sessionStart |
||
SessionStart |
|||
| green | |||
| You submit a prompt | beforeSubmitPrompt |
||
UserPromptSubmit |
|||
| red | |||
| A tool call begins | preToolUse |
||
PreToolUse |
|||
| red | |||
| The turn finishes | stop |
||
Stop |
|||
| green | |||
| Session closes | sessionEnd |
||
SessionEnd |
|||
| removed |
The plugin renders those files. Because hooks fire on state changes, report.py
also pokes SwiftBar's URL scheme so the dot flips immediately rather than waiting for the next poll. The refresh interval in the plugin filename exists only to clean up after sessions that died without a chance to say goodbye.
Two properties worth calling out, since this code runs inside your agent's critical path:
It cannot block or alter an agent.report.py
swallows every exception and always exits0
with empty stdout. A bug in it makes the dot wrong, not your session broken.It recovers from crashes. Each state file records the agent's real process ID. If a session isSIGKILL
ed or its terminal is closed, the plugin notices the process is gone and drops it, so you never get a dot stuck on red. A 30-minute stall timeout backs that up.
- macOS (Monterey or later, as SwiftBar requires)
SwiftBar—
install.sh
will install it via Homebrew if missingpython3
- At least one of Cursor CLIorClaude Code
git clone https://github.com/ajwadjaved/Hotaru.git
cd Hotaru
./install.sh
The installer copies the reporter to ~/.hotaru/
, drops the plugin into your SwiftBar plugin folder, merges the hook entries into your CLI configs and starts SwiftBar. It's safe to re-run.
Your existing config is merged, not replaced — ~/.claude/settings.json
in particular holds your model choice, status line and other settings. Every file is backed up before it's touched, and re-parsed before being saved.
After installing, start a new agent session.Both CLIs read their hook config only at startup, so sessions you already have open won't appear. This is the single most common surprise.
Manual install #
mkdir -p ~/.hotaru/sessions
install -m 755 src/report.py ~/.hotaru/report.py
install -m 755 src/hotaru.5s.py ~/.swiftbar/hotaru.5s.py # or your SwiftBar plugin folder
Then add the hook entries from examples/ to
~/.cursor/hooks.json
and ~/.claude/settings.json
. Use absolute paths — those commands are not shell-expanded, so ~
and $HOME
won't resolve. Or just run the merge step on its own:
python3 src/wire_hooks.py
Everything is two short Python files; edit them in place.
Colours are the constants at the top ofhotaru.5s.py
. They'reKanagawa Waveby default, so they match a typical Neovim or starship setup.SAMURAI_RED
is quite hot in a menu bar —#C34043
is a calmer alternative.Refresh interval is the filename. Rename tohotaru.10s.py
to halve the background work; state changes still appear instantly via the URL-scheme nudge.Dropdown contents are themain()
function.
After editing the plugin, refresh it:
open -g "swiftbar://refreshplugin?name=hotaru"
Four non-obvious behaviours, all of which cost real debugging time and are baked into the code now.
Hooks load at startup. Neither CLI re-reads its hook config mid-session, so changes only take effect in new sessions.
** -p print mode fires no hooks.** Running
cursor-agent -p "..."
non-interactively won't register a session. Only interactive sessions report, which is fine for the intended use but makes scripted testing misleading. To test, drive a real session — a tmux pane with send-keys
works well.A hook's parent process is not the agent. Hooks are spawned through a short-lived shell that exits immediately, so getppid()
is useless for liveness. report.py
walks up the process tree to find the long-lived agent process, stopping when it hits the terminal emulator. Without this, sessions get reaped seconds after they appear.
SwiftBar renders sfimage as a template image. That forces it monochrome, so
sfcolor
is silently ignored and you get a white blob. Hotaru draws a ●
text character coloured with color=
instead, which can't be overridden. sfcolor
only applies to SF Symbols embedded in the title text via :symbol:
syntax.The dot stays hollow grey. Confirm a session actually registered:
ls ~/.hotaru/sessions/ # one JSON file per live session
cat ~/.hotaru/events.log # every hook fired, with the resolved agent pid
An empty events log means the hooks aren't wired or the session predates them. Start a fresh session and check again.
The dot is there but never changes colour. Run the plugin by hand — its output tells you what SwiftBar is being asked to draw:
~/.swiftbar/hotaru.5s.py
A session lingers after closing. Only possible if the agent process is somehow still alive; the reaper is keyed on that. rm ~/.hotaru/sessions/*.json
clears the slate safely.
./uninstall.sh
Removes the hook entries, the plugin and ~/.hotaru
, leaving SwiftBar installed and your config backups in place.
A distinct colour for "waiting for your approval", which is currently lumped in with red because red means "not free". Claude Code's Notification
event gives this exactly; cursor-agent has no equivalent event, but a session blocked on a TTY read sits at 0% CPU, which is a reliable enough signal. Clicking a session to jump to its terminal is also within reach — Ghostty's AppleScript dictionary can focus a tab, and tmux can select a window.
MIT — see LICENSE.