dingger
macOS agent watcher for tmux panes: herdr's agent panel, without herdr. dingger watches every pane in your tmux server, detects which AI coding agent runs in each (claude, codex, pi, gemini, and 19 others), tracks herdr's five statuses, and tells you which agents are waiting for you: in the menu bar, in the tmux status line, and with a notification and a sound.
dingger owns no terminals. It is a client of your tmux server. The detection engine and rule manifests are extracted from herdr (Apache-2.0).
Statuses (herdr's, verbatim)
| status | meaning | symbol | color | priority |
|---|---|---|---|---|
| blocked | needs your input | × |
red | 4 |
| done | finished a turn while you were looking elsewhere | ✓ |
teal | 3 |
| working | turn in flight | ◐ |
yellow | 2 |
| idle | at its prompt, nothing new since you looked | ○ |
green | 1 |
The state machine is herdr's apply_pane_state_change and
mark_active_tab_seen, implemented as one pure function (watcher::advance)
and unit-tested against herdr's own test cases:
- a turn that finishes while you look at another window becomes done;
- a turn that finishes in the window you are looking at stays idle;
- looking at a window (a tmux window is herdr's tab) turns its done agents idle, as does jumping to one from the menu bar or status line;
- an agent seen for the first time is idle, not done.
dingger watches tmux focus through tmux hooks, so attendance updates the moment you switch windows; agent reports apply in order, so a turn shorter than a poll is still a completion.
Surfaces
- Menu bar (
menubar/main.swift):×1 ✓3 ◐2in herdr's colors; the menu lists every agent in priority order, oldest waiting first. Clicking a row jumps to that pane. - Floating panel (
menubar/panel.swift): an always-on-top HUD that never takes focus, on every Space and over full-screen apps. It lists the agents waiting for you (blocked, done) and summarizes the rest (◐ 2 working · ○ 15 idle); click a row to jump there, drag to move it, right-click an idle agent in the full list to mark it unread. ⌃⌥⌘E (or "Show Panel" in the menu) turns it on or off; off stays off until you turn it back on. While on, "Panel Shows" picks Only When Agents Are Waiting (default: it hides itself while nothing waits; turning it on then shows it for 3 seconds so you see it is armed; compact) or Always (with a "Show all / Show less" toggle for the full list). The panel is always dark, in herdr's palette, so it reads the same over any desktop. - Jump shortcut: ⌃⌥⌘U jumps to the top waiting agent from anywhere, like clicking the tmux status line.
- Agent search: ⌃⌥⇧⌘U opens a Spotlight-style search over every agent. Type to fuzzy-filter (each word must match, letters in order, the name, session, window, harness, status, or directory; or, as written, a forgotten draft); ↑↓ or ⌃N ⌃P / ⌃J ⌃K select, ↩ or a click jumps, esc closes, ⌃U clears, ⌘U (or a right-click) marks the selected idle agent unread. With nothing typed, agents are in the menu's order, so ↩ jumps to the top waiting agent. It takes the keyboard without activating dingger, so focus returns where it was.
- Restore: dingger saves your tmux layout as it changes (sessions,
windows, panes, layouts, directories) under
~/.local/state/dingger/snapshots/. After a crash, shutdown, orkill-server,dingger restorebrings back what is missing (--dry-runto preview,--listfor saved snapshots,--sessionfor some). Sessions that exist are left alone, except a bare one of the same name (what a terminal creates on startup), which is replaced with its clients moved over. Agents are resumed in their own sessions: pi withpi --session <file>(reported by the pi extension), Claude Code withclaude --resume <id>(reported by its SessionStart hook,dingger install claude), in the directory they ran in. A session that never got a message has nothing to resume, so its command line is typed instead. Agents that were mid-turn are listed. Other panes get the command line that was running typed at the prompt, not run: exactly as typed with the zsh hook (dingger install zsh, then source the file it names from your.zshrc), otherwise the program's arguments. An agent or program that exits within a minute before tmux stops (a shutdown) is still restored. When tmux stops, the panel (first row: "↺ Restore 9 sessions · 22 agents", or "Not now"), the menu (all, or one session at a time), and the status line (↺9, clickable) offer the restore; it runs through your login shell, like a terminal starting tmux, and a notification summarizes (full report:~/.local/state/dingger/restore.log). - All shortcuts live in
~/.config/dingger/config.toml(menu bar, Settings…), reloaded on save. - Jumping to an agent (any click, a shortcut, search) shows it in a terminal window and raises that window. With several terminal windows, one already on the agent's session wins (it shows the agent without switching), then the focused one, then the one used last. In Ghostty (1.3+), the exact window, tab, and split is raised: dingger learns which terminal holds each tmux client when it gains focus (tmux's focus hook asks Ghostty which terminal is focused; nothing visible changes). Other terminals: the app is activated.
- tmux status line (
packaging/tmux/dingger.tmux): the same summary after the clock. Clicking it jumps to the top waiting agent (the menu's top row), skipping the window you already look at. - Notifications: "needs attention" (blocked) and "finished" (done), with herdr's sounds; silent for completions you are already watching.
- Importance: mark an agent high, normal (the default), or low. Within a
status, more important agents are listed first, so a jump goes to them
first; status still comes first (a blocked low agent before a done high
one). A high agent shows a peach
!before its name (in a column of its own, so names line up); a low agent's name is dimmed, its second line says "low", and it makes no sound (it still notifies, and waits in line like any other). In pi, type/importance high|normal|low(no argument: the current level); pi saves it with the session, so it comes back after/reload, a restart, or a restore. For other agents, rundingger importance high|normal|lowin the agent's pane (in Claude Code:! dingger importance high, which Claude may answer with a model turn), or pass--pane; it lasts until the agent exits. - Mark as unread: like in mail, an idle agent (one you have seen) can be
made done again, to come back to later: right-click it in the panel's
full list, ⌘U in agent search,
/unreadin pi, ordingger unread [--pane]. It goes last among done agents of its importance, with no sound or notification, and stays done until you come back to it: you leave its tmux window and return, or jump to it. Only idle agents can be marked: a working, blocked, or done one is left alone. Claude Code has no/unread, and! dingger unreaddoes not work in it: Claude (2.1.311) answers a!command with a model turn, so the agent is working and the mark is refused. Use the panel or search for Claude agents. - What agents cost (pi): the menu's top line totals what pi agents
spent today, this week, and this month (
$8.86 today · $457 this week · $8.86 this month); each agent's line adds what its session spent today (at the right edge in the panel and search, after the place in the menu), anddingger statusshows both. Periods start at 00:00 UTC (a week on Monday, a month on its 1st). The daemon reads pi's session files (~/.pi/agent/sessions, and those of running agents elsewhere) in a thread of its own every 15 seconds, only what was appended since. Each reply counts once, for the session it first appeared in: a fork (/fork,pi --fork) copies its parent's replies into its own file, and summing every file would count them again (in one month here, $1,024 of $2,991). The totals include agents since closed or started over. Amounts are what pi prices each call at (its model configuration), counted as pi's footer counts them: replies, compaction and branch summaries, and other recorded calls. Claude Code records no prices, so it is not counted. - Context use (pi): how full each agent's context window is, the
number pi's own footer shows. The panel and search draw it as a small
meter at the right edge, after the cost (gray; yellow above 70% and red
above 90%, as in pi; the number on hover); the menu adds it as text
(
$0.70 · 10%), anddingger statushas a CTX column. The extension reports it after each message, compaction, model change, and turn; right after compacting pi does not know it until the next reply, and neither does dingger (no meter). Agents on an older extension show none until they reload. - Forgotten drafts (pi): an agent with text left in its input box shows
✎before its name wherever you are not looking at it (where you look, you may be typing it). Idle agents with one come first among idle agents; the compact panel counts them (✎ 2 drafts, a click jumps to the first); search finds an agent by its draft's words (as written, not fuzzily);dingger statusmarks them too. No sound, no notification. The extension reads the input box half a second after you stop typing, and keeps each session's draft in a file (~/.local/state/dingger/drafts/) that puts it back in the input box when the session comes back: after a crash, a restore, orpi --session. The draft is kept on the pane as one line (@dingger_draft), never in dingger's log.
Where state comes from
- Agent hooks (authoritative):
dingger install piinstalls a pi extension that reports working/blocked/idle over~/.local/state/dingger/agent.sock. Running pi agents load it when reloaded (see "Keeping pi agents on the current extension" below).dingger install claudeadds a SessionStart hook to~/.claude/settings.json(keeping everything else; the original is backed up tosettings.json.bak-dingger) that reports only Claude's session, for restore; its state comes from screen matching. - Screen matching (fallback):
capture-panetext through herdr's manifests, for agents without hooks.
All per-pane state lives in tmux pane options (@agent_state,
@dingger_since, @dingger_hook, @dingger_importance,
@dingger_extension, @dingger_control, @dingger_draft,
@dingger_cost, @dingger_context), so restarting dingger loses nothing.
Keeping pi agents on the current extension
When the daemon starts, it updates the installed pi extension if this
dingger's differs (only its own file, which says so on its first line).
Running agents keep the one they loaded, and report its version. While
some run an old one, the menu bar menu offers Reload N Agents on an Old
Extension; dingger reload-agents does the same. Each agent reloads
(pi's /reload) when:
- it is idle (done included: it stays done),
- it is not on screen, where you may be typing into it,
- and its input box is empty: the extension checks this itself, at the moment it reloads, so nothing you type is lost or sent.
The others reload once they are; the request stays until all are, or the
daemon restarts. dingger sends no keystrokes: it asks the extension over a
socket of its own (~/.local/state/dingger/pi-<pid>-<id>.sock), and the
extension runs the reload as a command (/dingger-reload, which you can
also type). A reload also resets pi's other extensions: a pending alarm
is lost, MCP servers reconnect.
An agent on an extension from before this (it reports no version) takes
no requests: the menu says how many need /reload by hand, once.
The pi extension's reports carry the agent's importance. Other pi
extensions can follow it on pi's event bus: dingger:importance with
{ level }, at session start and on every change; it is saved in the
session as a custom entry of type dingger-importance.
Usage
dingger watch # the daemon (run by launchd, see below)
dingger status # agents in priority order, with cost and context use
dingger focus [--pane ID] # jump to an agent (default: the top waiter)
dingger importance [LEVEL] # an agent's importance: high, normal, low
dingger unread [--pane ID] # mark an idle agent unread (done again)
dingger reload-agents # reload pi agents on an old extension
dingger install pi # install the pi state-reporting extension
dingger install claude # report Claude Code sessions, for restore
dingger install zsh # record typed command lines, for restore
dingger manifests update # refresh manifests from herdr's catalog
Install
cargo build --release
cp target/release/dingger ~/.local/bin/dingger # do not strip: invalidates
codesign --force --sign - ~/.local/bin/dingger # the signature, macOS kills it
swiftc -O -o target/dingger-menu menubar/*.swift # menu bar + panel, bundled
# as ~/Applications/dingger.app
- launchd agents (start at login, restart on exit):
packaging/launchd/dev.dingger.{dingger,menubar}.plist; adjust the user path, copy to~/Library/LaunchAgents/,launchctl bootstrap gui/$(id -u) <plist>. - tmux: you add the snippet yourself; no
dingger installcommand does it. Put this line at the end of your tmux config (~/.tmux.conf, or a file it sources):
tmux
source-file /path/to/dingger/packaging/tmux/dingger.tmux
Then reload: tmux source-file ~/.tmux.conf. The snippet:
- replaces status-right with the restore offer, %d-%b %H:%M, and
the agent counts. Your own status-right is lost, so put the line after
it, or edit the snippet's status-right to keep what you had;
- turns on focus-events, which raising the right Ghostty window needs;
- adds hooks that report focus and navigation to dingger. They use index
[77], so they keep your own hooks on the same events, and reloading
does not duplicate them;
- binds clicks on the right of the status line, which needs
set -g mouse on.
The line sources the snippet from the repo, so a changed snippet applies at the next reload.
dingger does not need tmux running first; it attaches when a server appears.
Testing
cargo test # engine tests + herdr state-machine tests
tests/e2e.sh # end to end against an isolated tmux server
tests/pi-live.sh <pi> # a real pi binary against dingger (new pi releases)
tests/e2e.sh starts its own tmux server, its own daemon (polling only every
30 s, so everything asserted must arrive through events), fake agents driven
over the hook protocol, and a real tmux client whose focus it controls. It
asserts every status transition, attendance by navigation, both click paths
(including the real menu bar binary), the floating panel's visibility and
layout rules and content (compact, full, toggle), agent search (matching,
ranking, selection), the status-line segment, latency with a
deliberately slow notifier, and state surviving a daemon restart. It never
touches your tmux server.
tests/pi-live.sh <pi binary> checks a real pi release on a tmux server and
daemon of its own, with a fake local model (tests/fake-model.py) and its
own pi agent directory: no network, no credentials, your pi untouched. It
covers identification, the extension's state and session reports, a turn,
/reload, an extension's blocked event, screen detection without the
extension, restore, reloading an agent on an old extension (and refusing
while busy or with a draft), drafts (reported, cleared, kept across a
crash and put back by restore), cost (priced replies, counted once across
a real pi --fork), context use (pi's footer's number, through a
compaction), and exit. To try a release without installing it:
npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>
(through sfw), then tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi.
Layout
crates/detect-core: the detection engine and 22 manifests, from herdr.crates/dinggerd: thedinggerbinary (watcher, hooks, focus, CLI).menubar/: the menu bar app (model.swiftrows and colors,panel.swiftthe floating panel,hotkey.swiftthe global shortcut and settings).packaging/: launchd agents and the tmux snippet.tests/e2e.sh: the end-to-end suite;tests/pi-live.sh: a real pi.
