aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-10 17:30:26 +0200
committerIgor Soarez <igor@soarez.org>2026-11-10 17:30:26 +0200
commit4cad1009c9d07cdfdab28fe9e073ccb35b54dfd7 (patch)
treef742ceac80ae61d1e893cfd57e080e84410e28cc
parent87001d7d380173c3d76246d4c0ecdd7b037a619f (diff)
readme: lead with what dingger shows, with real renders of made-up agents; move the details to docs/reference.md; dingger status: fit the agent column to its longest nameHEADmaster
-rw-r--r--README.md524
-rw-r--r--ROADMAP.md16
-rw-r--r--crates/dinggerd/src/main.rs8
-rwxr-xr-xdocs/demo-agents.sh67
-rw-r--r--docs/images/hero.pngbin0 -> 786071 bytes
-rw-r--r--docs/images/panel.pngbin0 -> 971029 bytes
-rw-r--r--docs/images/search.pngbin0 -> 401525 bytes
-rw-r--r--docs/images/statusline.pngbin0 -> 366796 bytes
-rw-r--r--docs/reference.md323
-rwxr-xr-xdocs/screenshots.sh198
10 files changed, 798 insertions, 338 deletions
diff --git a/README.md b/README.md
index 5df0473..8509260 100644
--- a/README.md
+++ b/README.md
@@ -1,342 +1,212 @@
-# 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](https://github.com/herdrdev/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 ◐2` in 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). It grows down
- to the bottom of the screen at most; a longer list scrolls, with the
- summary line kept under it. 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, or
- `kill-server`, `dingger restore` brings back what is missing (`--dry-run`
- to preview, `--list` for saved snapshots, `--session` for 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 with `pi --session
- <file>` (reported by the pi extension), Claude Code with `claude --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. A pi
- run that ends in an error (after pi's own retries) is still done, but
- says so: "failed" with the error as one readable line (`401: invalid
- x-api-key`), with the "needs attention" sound, and `failed 3m · <why>`
- in red where the wait shows (plain text in the menu; `dingger status`
- too), until you have seen it or it runs again.
-- **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,
- run `dingger importance high|normal|low` in 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, `/unread` in pi, or `dingger 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 unread` does 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), and `dingger status` shows 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%`), and `dingger status` has 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.
-- **Session names** (pi): every pi session gets a short name of what it is
- about, from the model, after its first run settles; it is refreshed
- every 8 of your messages, and an old unnamed session is named when you
- resume it. A name you give (`/name`, `pi --name`, `/autoname <name>`) is
- never replaced; `/autoname` alone renames now. It runs in every pi
- session (print runs and outside tmux too) and costs one short model call
- per naming; `PI_AUTONAME_MODEL=provider/model-id` picks a cheaper model.
- It replaces the `pi-auto-session-name` package (remove it: `pi remove
- git:github.com/Optomatica/pi-auto-session-name`), and keeps the names
- that package gave automatic.
-- **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 status` marks 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, or `pi --session`.
- The draft is kept on the pane as one line (`@dingger_draft`), never in
- dingger's log.
-
-## Where state comes from
-
-1. **Agent hooks** (authoritative): `dingger install pi` installs 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).
- It also reports the session's name (`/name`, or pi's own), which is
- the agent's name in dingger whatever the terminal title says: any
- program in the pane can set a title. Agents without it (Claude Code, a
- pi on an older extension) are named from their terminal title.
- Blocked covers a dialog that opens during a run (an extension asking
- before it goes on), until you answer it; one you open while the agent
- is idle changes nothing. A run you cancel (Escape) ends idle, not done,
- with no "finished" notification, even when it settles after you look
- away.
- `dingger install claude` adds a SessionStart hook to
- `~/.claude/settings.json` (keeping everything else; the original is
- backed up to `settings.json.bak-dingger`) that reports only Claude's
- session, for restore; its state comes from screen matching.
-2. **Screen matching** (fallback): `capture-pane` text 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`, `@dingger_error`), 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`.
+<h1 align="center">dingger</h1>
+
+<p align="center">
+ <b>Know which of your AI coding agents is waiting for you.</b><br>
+ herdr's agent panel for the tmux you already run, on macOS.
+</p>
+
+![dingger: a floating panel of the agents waiting for you, their counts in the menu bar and the tmux status line](docs/images/hero.png)
+
+You run a dozen coding agents in tmux panes. One finished ten minutes ago,
+one is stuck on a permission prompt, one hit a rate limit, and you are
+reading a diff somewhere else. dingger watches every pane in your tmux
+server, works out which agent runs where and what it is doing, and tells you
+who is waiting for you. Then it takes you there in one keystroke.
+
+- **Every agent at a glance.** It detects 22 agents: Claude Code, Codex, pi,
+ Gemini, opencode, Cursor, GitHub Copilot, Amp, and 14 more.
+- **Waiting agents come to you.** A notification and a sound when an agent
+ finishes or needs your input, and silence when you are already watching it.
+- **One key to the next agent.** <kbd>⌃⌥⌘U</kbd> jumps to the next agent
+ waiting for you, and raises the exact Ghostty window, tab, and split.
+- **Find any agent.** <kbd>⌃⌥⇧⌘U</kbd> opens a Spotlight-style fuzzy search.
+- **Survives a restart.** dingger saves your tmux layout as you work, and
+ after a reboot or crash brings back every session, window, and pane, with
+ each agent resuming its own conversation.
+- **Nothing to migrate.** No new terminal, no new multiplexer: dingger is a
+ client of your tmux server, and keeps its state in tmux options.
+
+## Statuses
+
+dingger tracks [herdr](https://github.com/herdrdev/herdr)'s statuses, with
+herdr's state machine:
+
+| | status | meaning |
+|---|---|---|
+| `×` | **blocked** | needs your input: a permission prompt, a question, a dialog |
+| `✓` | **done** | finished a turn while you were looking elsewhere |
+| `◐` | **working** | a turn is in flight |
+| `○` | **idle** | at its prompt, nothing new since you looked |
+
+A turn that finishes in the tmux window you are looking at stays idle.
+Looking at a window marks its agents seen. Waiting agents are listed blocked
+first, then done, each oldest first. The top one is the next waiting agent:
+where a jump goes.
+
+## The floating panel
+
+![The floating panel: compact, with a restore offer after a restart, and with the full list](docs/images/panel.png)
+
+An always-on-top panel that never takes focus, on every Space and over
+full-screen apps. Click a row to jump to that agent, drag the panel anywhere.
+It can show itself only while agents are waiting, or always, with the full
+list a click away. <kbd>⌃⌥⌘E</kbd> turns it on and off.
+
+Each row says where the agent is (session, window, harness) and for how long
+it has waited. pi agents also show what they spent today and how full their
+context window is.
+
+## Agent search
+
+![Agent search: "edit" fuzzy-matches four agents](docs/images/search.png)
+
+<kbd>⌃⌥⇧⌘U</kbd> from anywhere. Type a few letters of an agent's name,
+session, window, harness, status, or directory, or a word of a draft you left
+in its input box. <kbd>↩</kbd> jumps there. With nothing typed,
+<kbd>↩</kbd> goes to the next waiting agent. Focus goes back
+where it was when you close it.
+
+## The tmux status line
+
+![The status line's counts in a dark and a light terminal theme](docs/images/statusline.png)
+
+The same counts after the clock, in your terminal theme's own colors. Click
+them to jump to the next waiting agent.
+
+The menu bar shows the counts too. Its menu lists every agent and what pi
+agents spent today, this week, and this month.
+
+## Restore after a restart
+
+dingger saves your tmux layout as it changes: sessions, windows, panes,
+layouts, directories, and what runs in each pane. When tmux stops (a
+restart, a crash, `kill-server`), the panel, the menu, and the status line
+offer to bring it all back. Agents resume their own sessions: pi with
+`pi --session`, Claude Code with `claude --resume`. Other programs get their
+command line typed at the prompt but not run, so nothing starts behind
+your back.
+
+## More for pi agents
+
+With dingger's pi extension (`dingger install pi`):
+
+- **Exact states.** The extension reports state directly, including dialogs
+ during a run, cancelled runs, and failed ones: "failed 3m · 429: rate limit
+ exceeded".
+- **Session names.** Every session gets a short name of what it is about,
+ from the model, kept current as the work moves on. A name you give it with
+ `/name` is never replaced.
+- **What agents cost.** Today, this week, and this month, counted the way
+ pi's footer counts, with forked sessions counted once.
+- **Context use.** A meter per agent that turns yellow above 70% and red
+ above 90%.
+- **Forgotten drafts.** `✎` marks an agent with text left in its input box,
+ and restore puts the draft back.
+- **Importance.** `/importance high` puts an agent ahead of the others with
+ its status and marks it `!`. A low agent is quiet and dimmed.
+- **Mark as unread.** `/unread` turns an agent done again, to come back to.
+- **Kept current.** When dingger updates its extension, the menu offers to
+ reload the agents on the old one. Each reloads only when it is idle, out of
+ sight, and has an empty input box.
+
+Claude Code and the other agents get the rest: detection from the screen,
+statuses, notifications, jumping, search, and restore.
-## Usage
+## Install
+
+dingger runs on macOS, with tmux. You need Rust, and Xcode's command line
+tools for the menu bar app.
```bash
-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
+# the daemon and CLI
+cargo build --release
+cp target/release/dingger ~/.local/bin/dingger
+codesign --force --sign - ~/.local/bin/dingger # sign the copy, or macOS kills it
+
+# the menu bar app, the panel, and search
+swiftc -O -o target/dingger-menu menubar/*.swift # bundle it as ~/Applications/dingger.app
```
-## Install
+1. **Start at login.** Adjust the user path in
+ `packaging/launchd/dev.dingger.{dingger,menubar}.plist`, copy both to
+ `~/Library/LaunchAgents/`, then run
+ `launchctl bootstrap gui/$(id -u) <plist>` for each.
+2. **Add the status line.** At the end of your tmux config, add the
+ following line, then reload with `tmux source-file ~/.tmux.conf`:
+
+ ```tmux
+ source-file /path/to/dingger/packaging/tmux/dingger.tmux
+ ```
+
+ It replaces `status-right`, turns on `focus-events`, and adds hooks
+ that tell dingger where you are looking. Clicking the counts needs
+ `set -g mouse on`.
+3. **Connect your agents** (optional, recommended):
+
+ ```bash
+ dingger install pi # pi: exact states, names, cost, drafts, restore
+ dingger install claude # Claude Code: its session, for restore
+ dingger install zsh # your exact command lines, for restore
+ ```
+
+Shortcuts live in `~/.config/dingger/config.toml` (Settings… in the menu).
+[Install in detail](docs/reference.md#install-in-detail) covers what each
+step changes.
+
+## Usage
```bash
-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
+dingger status # every agent, in the order they wait
+dingger focus # jump to the next waiting agent
+dingger restore --dry-run # what a restore would bring back
+dingger importance high # in an agent's pane: list it first
+dingger unread # in an agent's pane: mark it unread
+dingger reload-agents # move pi agents onto the current extension
+dingger manifests update # refresh detection rules from herdr's catalog
+```
+
+`dingger status`, abridged:
+
+```text
+STATUS WHERE AGENT COST CTX NAME
+blocked infra · certs pi $0.42 18% ! Rotate staging TLS certificates
+blocked api · db claude Approve migration for the orders table
+failed web · deps pi $0.97 54% Upgrade to React 19 · 429: rate limit exceeded
+done api · tests pi $1.84 38% Fix flaky retry test in the HTTP client
+working api · perf pi $3.10 76% Profile the slow search endpoint
+idle cli · config pi $2.15 61% ✎ Refactor the config loader
+
+spent: $23.40 today · $187.15 this week · $612.80 this month (UTC)
```
-- 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 install` command 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.
+## How it works
+
+A daemon (`dingger watch`) polls your tmux server every second and wakes at
+once on agent reports and tmux focus hooks. It decides each pane's status
+from the agent's own reports where an integration exists, and otherwise
+from the screen, through herdr's detection rules. It writes everything to
+tmux pane options, so the menu bar app, the panel, and the status line are
+stateless readers, and restarting dingger loses nothing. dingger owns no
+terminals, and does not need tmux to be running when it starts.
+
+The [reference](docs/reference.md) has the details: every feature's exact
+behavior, where state comes from, and how the pi extension is kept current.
## Testing
```bash
-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/shutdown.sh [dingger] [rounds] # restore after the ways an OS stops tmux
+cargo test # engine and state-machine tests
+tests/e2e.sh # end to end, on a tmux server of its own
+tests/pi-live.sh <pi> # a real pi release, with a fake model
+tests/shutdown.sh # restore after the ways an OS stops tmux
```
-`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, dialogs (blocked during a run,
-nothing while idle), a cancelled run (idle, not done), a failed run (done,
-with why; cleared when seen or run again), names (the session's, not the
-terminal title's; named after the first run, renamed every 8 messages,
-yours kept, print runs and resumed old sessions named), screen detection
-without the
-extension, restore (after tmux and its real pis are stopped as a restart
-stops them: SIGTERM to all at once, then SIGKILL), 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`.
-
-`tests/shutdown.sh` stops tmux the ways a restart or logout can, each on
-servers, a daemon and a state directory of its own, with fake agents: SIGTERM
-to tmux; to the agents first; the daemon first; the terminal quitting first;
-and, for 20 rounds each by default, SIGTERM and SIGKILL to everything at once
-(tmux, its panes, the terminal's client, the daemon) in a random order at a
-random moment. After each it logs in again (a fresh daemon if its own was
-stopped; a terminal running `tmux new-session -As 0`) and checks the old
-layout is offered and restoring brings back every session, window, pane,
-directory, agent and command.
-
-## Layout
-
-- `crates/detect-core`: the detection engine and 22 manifests, from herdr.
-- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI).
-- `menubar/`: the menu bar app (`model.swift` rows and colors, `panel.swift`
- the floating panel, `hotkey.swift` the 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.
+None of them touch your tmux server, your agents, or your pi setup.
+[Testing](docs/reference.md#testing) lists what each covers. The README's
+images are real renders of made-up agents: `docs/screenshots.sh` makes them.
+
+## Credits
+
+dingger's detection engine and agent rules come from
+[herdr](https://github.com/herdrdev/herdr) (Apache-2.0), and so do its
+statuses, state machine, colors, and sounds.
diff --git a/ROADMAP.md b/ROADMAP.md
index 8a61f17..1c6b03c 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -3,9 +3,9 @@
In priority order.
1. ~~**Restore after a crash or shutdown.**~~ Done for pi and Claude Code
- (see the README). Next, when used: other agents' session references
+ (see docs/reference.md). Next, when used: other agents' session references
(codex, ...).
-2. ~~**Raise the right window.**~~ Done for Ghostty (see the README),
+2. ~~**Raise the right window.**~~ Done for Ghostty (see docs/reference.md),
tested live with several windows, one of them full screen on its own
Space, and from other apps. Next, when used: Alacritty with several
windows in one process.
@@ -25,14 +25,14 @@ In priority order.
- A pipe started after pi misses the reports sent before it.
- How to set `PI_PROGRAM_STATUS=1` for pi: the tmux global environment,
or the shell profile.
-4. ~~**Blocked on a pi dialog.**~~ Done (see the README): pi 1.4 tells
+4. ~~**Blocked on a pi dialog.**~~ Done (see docs/reference.md): pi 1.4 tells
extensions when a dialog opens and closes (`ui_prompt_start`/`_end`).
A dialog during a run is blocked; one opened while idle is not. Next,
with item 9: the dialog's title in the notification and panel.
-5. ~~**Cancelled runs are not done.**~~ Done (see the README): the
+5. ~~**Cancelled runs are not done.**~~ Done (see docs/reference.md): the
extension reports `aborted` with the idle that ends a cancelled run,
and the daemon keeps it idle, with no "finished" alert.
-6. ~~**Failed runs.**~~ Done for pi (see the README): still done, but
+6. ~~**Failed runs.**~~ Done for pi (see docs/reference.md): still done, but
the alert says "failed" and why, with the "needs attention" sound,
and the second line shows it in red until seen. In 30 days, 88 of
3,959 runs (2.2%) failed: network errors, timeouts, and keys or
@@ -77,11 +77,11 @@ Ideas, not yet prioritized:
idle, send blocked agents to your phone (a push service such as ntfy
or Pushover). Sends data off the machine: needs explicit setup.
-15. ~~**What each agent costs.**~~ Done for pi (see the README): today,
+15. ~~**What each agent costs.**~~ Done for pi (see docs/reference.md): today,
this week and this month in the menu, each agent's today on its line,
UTC periods, forks counted once, closed agents included. No limits or
warnings, by choice.
-16. ~~**Keep agents on the current extension.**~~ Done (see the README):
+16. ~~**Keep agents on the current extension.**~~ Done (see docs/reference.md):
the daemon updates the installed extension, and reloads agents on an
old one over a control socket, from the menu or `dingger
reload-agents`. The socket is also the way to items 11 (closing an
@@ -91,7 +91,7 @@ Ideas, not yet prioritized:
18. **Warn about uncommitted work.** Mark agents whose directory has
uncommitted git changes; mainly a safety check before closing a stale
agent (item 11).
-19. ~~**Forgotten drafts.**~~ Done for pi (see the README): marked where
+19. ~~**Forgotten drafts.**~~ Done for pi (see docs/reference.md): marked where
you are not looking, first among idle agents, counted in the compact
panel, found by search, and kept across a crash or restore. Claude Code
would need screen matching of its input box.
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 0ace3a9..2881d4a 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -468,10 +468,12 @@ fn status() -> Result<(), String> {
});
let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1);
let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
- println!("{:<8} {:<width$} {:<7} {:>8} {:>4} NAME", "STATUS", "WHERE", "AGENT", "COST", "CTX");
+ let label = |pane: &tmux::Pane| pane.agent.map(detect_core::agent_label).unwrap_or("-");
+ let agents = panes.iter().map(|pane| label(pane).chars().count()).max().unwrap_or(5).max(5);
+ println!("{:<8} {:<width$} {:<agents$} {:>8} {:>4} NAME", "STATUS", "WHERE", "AGENT", "COST", "CTX");
for pane in &panes {
let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
- let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
+ let agent = label(pane);
let draft = if watcher::forgotten_draft(pane) { "✎ " } else { "" };
let name = match importance::Importance::from_option(&pane.written_importance) {
importance::Importance::High => format!("! {}", pane.written_name),
@@ -486,7 +488,7 @@ fn status() -> Result<(), String> {
} else {
("failed", format!(" · {}", pane.written_error))
};
- println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}{why}", place(pane), name.trim());
+ println!("{state:<8} {:<width$} {agent:<agents$} {cost:>8} {context:>4} {draft}{}{why}", place(pane), name.trim());
}
let global = |name: &str| tmux::show_global(name).unwrap_or_default();
let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")]
diff --git a/docs/demo-agents.sh b/docs/demo-agents.sh
new file mode 100755
index 0000000..86f1436
--- /dev/null
+++ b/docs/demo-agents.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+# A tmux server of made-up agents, for the README's screenshots: what the
+# daemon would write to pane options, written directly (no agents run).
+#
+# docs/demo-agents.sh <socket-name> <dir>
+#
+# Every pane runs a copy of `sleep`, in / (search matches directories too,
+# and a temporary one would match almost any query); nothing touches any
+# other tmux server.
+set -eu
+SOCK=$1
+DIR=$2
+t() { tmux -u -L "$SOCK" "$@"; }
+now=$(($(date +%s) * 1000))
+ago() { echo $((now - $1 * 60000)); }
+
+t kill-server 2>/dev/null || true
+mkdir -p "$DIR/bin"
+# Each pane runs a copy of sleep named after its agent, so process detection
+# (`dingger status`) finds it.
+for harness in pi claude codex gemini opencode; do
+ cp /bin/sleep "$DIR/bin/$harness" && codesign --force --sign - "$DIR/bin/$harness" 2>/dev/null
+done
+# A placeholder session holds the server up while the others are made.
+t -f /dev/null new-session -d -s _ -x 200 -y 50 "sleep 100000"
+
+pane=
+agent() { # session window harness state minutes name [option=value...]
+ local session=$1 window=$2 harness=$3 state=$4 minutes=$5 name=$6
+ shift 6
+ local run="$DIR/bin/$harness 100000"
+ if ! t has-session -t "=$session" 2>/dev/null; then
+ t new-session -d -s "$session" -n "$window" -c / "$run"
+ elif ! t list-windows -t "=$session" -F '#{window_name}' | grep -qx "$window"; then
+ t new-window -d -t "=$session:" -n "$window" -c / "$run"
+ else
+ t split-window -d -t "=$session:$window" -c / "$run"
+ fi
+ pane=$(t list-panes -t "=$session:$window" -F '#{pane_id}' | tail -1)
+ t set -p -t "$pane" @agent "$harness"
+ t set -p -t "$pane" @agent_state "$state"
+ t set -p -t "$pane" @dingger_since "$(ago "$minutes")"
+ t set -p -t "$pane" @agent_name "$name"
+ for kv in "$@"; do t set -p -t "$pane" "@${kv%%=*}" "${kv#*=}"; done
+}
+
+agent api db claude blocked 2 "Approve migration for the orders table"
+agent infra certs pi blocked 6 "Rotate staging TLS certificates" dingger_importance=high dingger_cost=0.42 dingger_context=18
+agent api tests pi done 4 "Fix flaky retry test in the HTTP client" dingger_cost=1.84 dingger_context=38
+agent web settings codex done 12 "Add a dark mode toggle to settings"
+agent web deps pi done 9 "Upgrade to React 19" dingger_cost=0.97 dingger_context=54 "dingger_error=429: rate limit exceeded"
+agent api perf pi working 3 "Profile the slow search endpoint" dingger_cost=3.10 dingger_context=76
+agent docs adr gemini working 1 "Write an ADR for the event bus"
+agent cli release pi working 7 "Cut the 2.4 release notes" dingger_cost=0.66 dingger_context=22
+agent web review claude idle 25 "Review PR #412: lazy-load the editor"
+agent cli config pi idle 40 "Refactor the config loader" "dingger_draft=also handle XDG_CONFIG_HOME" dingger_cost=2.15 dingger_context=61
+agent infra audit pi idle 90 "Nightly dependency audit" dingger_importance=low dingger_cost=0.31 dingger_context=9
+agent docs site opencode idle 55 "Migrate the docs site to Astro"
+agent api auth pi idle 130 "Add passkey sign-in" dingger_cost=4.02 dingger_context=93
+t kill-session -t =_
+
+t set -g @dingger_blocked 2
+t set -g @dingger_done 3
+t set -g @dingger_working 3
+t set -g @dingger_cost_today 23.40
+t set -g @dingger_cost_week 187.15
+t set -g @dingger_cost_month 612.80
diff --git a/docs/images/hero.png b/docs/images/hero.png
new file mode 100644
index 0000000..b3d734c
--- /dev/null
+++ b/docs/images/hero.png
Binary files differ
diff --git a/docs/images/panel.png b/docs/images/panel.png
new file mode 100644
index 0000000..99870de
--- /dev/null
+++ b/docs/images/panel.png
Binary files differ
diff --git a/docs/images/search.png b/docs/images/search.png
new file mode 100644
index 0000000..be8b2dd
--- /dev/null
+++ b/docs/images/search.png
Binary files differ
diff --git a/docs/images/statusline.png b/docs/images/statusline.png
new file mode 100644
index 0000000..1395b18
--- /dev/null
+++ b/docs/images/statusline.png
Binary files differ
diff --git a/docs/reference.md b/docs/reference.md
new file mode 100644
index 0000000..e159880
--- /dev/null
+++ b/docs/reference.md
@@ -0,0 +1,323 @@
+# dingger reference
+
+The details behind the [README](../README.md): how each part behaves, where
+state comes from, and how dingger is tested.
+
+## 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.
+
+## Features in detail
+
+- **Menu bar** (`menubar/main.swift`): `×1 ✓3 ◐2` in 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). It grows down
+ to the bottom of the screen at most; a longer list scrolls, with the
+ summary line kept under it. 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, or
+ `kill-server`, `dingger restore` brings back what is missing (`--dry-run`
+ to preview, `--list` for saved snapshots, `--session` for 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 with `pi --session
+ <file>` (reported by the pi extension), Claude Code with `claude --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. A pi
+ run that ends in an error (after pi's own retries) is still done, but
+ says so: "failed" with the error as one readable line (`401: invalid
+ x-api-key`), with the "needs attention" sound, and `failed 3m · <why>`
+ in red where the wait shows (plain text in the menu; `dingger status`
+ too), until you have seen it or it runs again.
+- **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,
+ run `dingger importance high|normal|low` in 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, `/unread` in pi, or `dingger 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 unread` does 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), and `dingger status` shows 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%`), and `dingger status` has 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.
+- **Session names** (pi): every pi session gets a short name of what it is
+ about, from the model, after its first run settles; it is refreshed
+ every 8 of your messages, and an old unnamed session is named when you
+ resume it. A name you give (`/name`, `pi --name`, `/autoname <name>`) is
+ never replaced; `/autoname` alone renames now. It runs in every pi
+ session (print runs and outside tmux too) and costs one short model call
+ per naming; `PI_AUTONAME_MODEL=provider/model-id` picks a cheaper model.
+ It replaces the `pi-auto-session-name` package (remove it: `pi remove
+ git:github.com/Optomatica/pi-auto-session-name`), and keeps the names
+ that package gave automatic.
+- **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 status` marks 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, or `pi --session`.
+ The draft is kept on the pane as one line (`@dingger_draft`), never in
+ dingger's log.
+
+## Where state comes from
+
+1. **Agent hooks** (authoritative): `dingger install pi` installs 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).
+ It also reports the session's name (`/name`, or pi's own), which is
+ the agent's name in dingger whatever the terminal title says: any
+ program in the pane can set a title. Agents without it (Claude Code, a
+ pi on an older extension) are named from their terminal title.
+ Blocked covers a dialog that opens during a run (an extension asking
+ before it goes on), until you answer it; one you open while the agent
+ is idle changes nothing. A run you cancel (Escape) ends idle, not done,
+ with no "finished" notification, even when it settles after you look
+ away.
+ `dingger install claude` adds a SessionStart hook to
+ `~/.claude/settings.json` (keeping everything else; the original is
+ backed up to `settings.json.bak-dingger`) that reports only Claude's
+ session, for restore; its state comes from screen matching.
+2. **Screen matching** (fallback): `capture-pane` text 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`, `@dingger_error`), 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`.
+
+## Install in detail
+
+```bash
+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 install` command 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
+
+```bash
+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/shutdown.sh [dingger] [rounds] # restore after the ways an OS stops tmux
+```
+
+`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, dialogs (blocked during a run,
+nothing while idle), a cancelled run (idle, not done), a failed run (done,
+with why; cleared when seen or run again), names (the session's, not the
+terminal title's; named after the first run, renamed every 8 messages,
+yours kept, print runs and resumed old sessions named), screen detection
+without the
+extension, restore (after tmux and its real pis are stopped as a restart
+stops them: SIGTERM to all at once, then SIGKILL), 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`.
+
+`tests/shutdown.sh` stops tmux the ways a restart or logout can, each on
+servers, a daemon and a state directory of its own, with fake agents: SIGTERM
+to tmux; to the agents first; the daemon first; the terminal quitting first;
+and, for 20 rounds each by default, SIGTERM and SIGKILL to everything at once
+(tmux, its panes, the terminal's client, the daemon) in a random order at a
+random moment. After each it logs in again (a fresh daemon if its own was
+stopped; a terminal running `tmux new-session -As 0`) and checks the old
+layout is offered and restoring brings back every session, window, pane,
+directory, agent and command.
+
+## Layout
+
+- `crates/detect-core`: the detection engine and 22 manifests, from herdr.
+- `crates/dinggerd`: the `dingger` binary (watcher, hooks, focus, CLI).
+- `menubar/`: the menu bar app (`model.swift` rows and colors, `panel.swift`
+ the floating panel, `hotkey.swift` the global shortcut and settings).
+- `packaging/`: launchd agents and the tmux snippet.
+- `tests/`: the end-to-end suite (`e2e.sh`), a real pi (`pi-live.sh`),
+ shutdowns (`shutdown.sh`).
+- `docs/`: this reference, the README's images, and the script that makes
+ them (`screenshots.sh`, from made-up agents in `demo-agents.sh`).
diff --git a/docs/screenshots.sh b/docs/screenshots.sh
new file mode 100755
index 0000000..ffe7291
--- /dev/null
+++ b/docs/screenshots.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env bash
+# The README's images, from made-up agents on a tmux server of their own.
+#
+# docs/screenshots.sh
+#
+# Needs a release build (cargo build --release), the menu bar app
+# (target/dingger-menu), and Google Chrome (headless, to compose the
+# images). The panel and search are drawn by the real app
+# (--snapshot-panel, --snapshot-search); the terminal is a real tmux client's
+# screen, colored as two terminal themes would color it. The menu bar strip
+# and window frames are drawn around them. Writes docs/images/*.png.
+set -eu
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+OUT="$ROOT/docs/images"
+WORK=$(mktemp -d /tmp/dingger-shots.XXXXXX)
+SOCK=dingger-demo-$$
+TERMSOCK=dingger-demo-term-$$
+CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+DINGGER="$ROOT/target/release/dingger"
+MENU="$ROOT/target/dingger-menu"
+trap 'tmux -L "$SOCK" kill-server 2>/dev/null; tmux -L "$TERMSOCK" kill-server 2>/dev/null; [ -n "${SHOTS_KEEP:-}" ] && echo "kept $WORK" || rm -rf "$WORK"' EXIT
+mkdir -p "$OUT"
+
+"$ROOT/docs/demo-agents.sh" "$SOCK" "$WORK/demo"
+t() { tmux -u -L "$SOCK" "$@"; }
+app() { env DINGGER_TMUX_SOCKET="$SOCK" DINGGER_STATE_DIR="$WORK/state" "$MENU" "$@"; }
+
+# --- the app's own renders ----------------------------------------------------
+mkdir -p "$WORK/state"
+app --snapshot-panel "$WORK/panel.png"
+app --snapshot-panel "$WORK/panel-full.png" --full
+matched=$(app --print-search edit | tail -1 | cut -c1-7)
+[ "$matched" = "4 of 13" ] || { echo "search: 'edit' should match 4 of 13 agents, got: $matched"; exit 1; }
+app --snapshot-search "$WORK/search.png" "edit"
+# After a restart: the offer to restore.
+saved=$(($(date +%s) * 1000 - 3 * 60000))
+cat >"$WORK/state/offer.json" <<EOF
+{"saved_at": $saved, "agents": 22, "interrupted": 2, "sessions": [
+ {"name": "api", "windows": 6, "panes": 9, "agents": 7}, {"name": "web", "windows": 4, "panes": 6, "agents": 5},
+ {"name": "infra", "windows": 3, "panes": 4, "agents": 3}, {"name": "docs", "windows": 3, "panes": 3, "agents": 2},
+ {"name": "cli", "windows": 4, "panes": 5, "agents": 3}, {"name": "scratch", "windows": 2, "panes": 2, "agents": 0},
+ {"name": "notes", "windows": 1, "panes": 1, "agents": 0}, {"name": "ops", "windows": 2, "panes": 3, "agents": 2},
+ {"name": "0", "windows": 1, "panes": 1, "agents": 0}]}
+EOF
+app --snapshot-panel "$WORK/panel-restore.png"
+rm "$WORK/state/offer.json"
+
+# --- a terminal: tmux with `dingger status` and the status line ---------------
+prompt='\033[1;34m~/src/api\033[0m \033[35m❯\033[0m'
+cat >"$WORK/term.sh" <<EOF
+#!/bin/sh
+clear
+printf '$prompt dingger status\n'
+DINGGER_TMUX_SOCKET=$SOCK $DINGGER status
+printf '\n$prompt '
+exec sleep 100000
+EOF
+chmod +x "$WORK/term.sh"
+t new-session -d -s main -n api -c / "$WORK/term.sh"
+t set -g status-style "fg=colour244,bg=default"
+t set -g status-left "#[fg=blue,bold] #S #[default]"
+t set -g status-left-length 20
+t set -g window-status-format " #I:#W "
+t set -g window-status-current-format "#[fg=default,bold] #I:#W* #[default]"
+t set -g status-right-length 80
+grep '^set -g status-right' "$ROOT/packaging/tmux/dingger.tmux" >"$WORK/status.tmux"
+t source-file "$WORK/status.tmux"
+tmux -u -L "$TERMSOCK" -f /dev/null new-session -d -x 100 -y 20 \
+ "exec env -u TMUX TERM=xterm-256color tmux -u -L $SOCK attach -t main"
+sleep 1.5
+tmux -u -L "$TERMSOCK" capture-pane -p -e -t 0 >"$WORK/term.ansi"
+
+# ANSI to HTML spans: classes for the 16 colors (each theme styles them),
+# inline colors for the 256-color grays.
+python3 - "$WORK/term.ansi" "$WORK/term.html" "$WORK/term-tail.html" <<'PY'
+import html, re, sys
+lines = open(sys.argv[1]).read().rstrip("\n").split("\n")
+fg, bold, dim = None, False, False
+def gray(n):
+ if n < 16: return None
+ if n >= 232: v = 8 + (n - 232) * 10; return f"rgb({v},{v},{v})"
+ n -= 16; return "rgb(%d,%d,%d)" % tuple(0 if c == 0 else 55 + c * 40 for c in (n // 36, n // 6 % 6, n % 6))
+def span(s):
+ if not s: return ""
+ cls, style = [], ""
+ if isinstance(fg, int): cls.append(f"c{fg}")
+ elif fg: style = f"color:{fg}"
+ if bold: cls.append("b")
+ if dim: cls.append("d")
+ return f'<span class="{" ".join(cls)}" style="{style}">{html.escape(s)}</span>'
+def convert(text):
+ global fg, bold, dim
+ fg, bold, dim = None, False, False
+ out = []
+ for part in re.split(r"(\x1b\[[0-9;]*m)", text):
+ m = re.fullmatch(r"\x1b\[([0-9;]*)m", part)
+ if not m:
+ out.append(span(part)); continue
+ codes = [int(c) if c else 0 for c in m.group(1).split(";")]
+ i = 0
+ while i < len(codes):
+ c = codes[i]
+ if c == 0: fg, bold, dim = None, False, False
+ elif c == 1: bold = True
+ elif c == 2: dim = True
+ elif c == 22: bold = dim = False
+ elif 30 <= c <= 37: fg = c - 30
+ elif 90 <= c <= 97: fg = c - 90 + 8
+ elif c == 39: fg = None
+ elif c == 38 and codes[i + 1] == 5:
+ n = codes[i + 2]; fg = n if n < 16 else gray(n); i += 2
+ elif c == 38 and codes[i + 1] == 2:
+ fg = "rgb(%d,%d,%d)" % tuple(codes[i + 2:i + 5]); i += 4
+ elif c in (48,):
+ i += 2 if codes[i + 1] == 5 else 4
+ i += 1
+ return "".join(out)
+open(sys.argv[2], "w").write(convert("\n".join(lines)))
+# The prompt and the status line.
+open(sys.argv[3], "w").write(convert("\n".join(lines[-2:])))
+PY
+
+# --- compose ---------------------------------------------------------------------
+# A snapshot is the view drawn twice, over white and over black, side by side
+# (points: 2w+20 wide); the second copy is the one composed here.
+size() { sips -g pixelWidth -g pixelHeight "$1" | awk '/pixelWidth/{w=$2} /pixelHeight/{h=$2} END{print w/2, h/2}'; }
+crop() { # name png -> CSS for a div showing the second copy
+ read -r w h < <(size "$2")
+ local half=$(echo "($w - 20) / 2" | bc)
+ echo ".$1{width:${half}px;height:${h}px;background:url('$2') no-repeat;background-size:${w}px ${h}px;background-position:-$((half + 20))px 0}"
+}
+TERM_HTML=$(cat "$WORK/term.html")
+TERM_TAIL=$(cat "$WORK/term-tail.html")
+cat >"$WORK/style.css" <<EOF
+$(crop panel "$WORK/panel.png")
+$(crop panelfull "$WORK/panel-full.png")
+$(crop panelrestore "$WORK/panel-restore.png")
+$(crop search "$WORK/search.png")
+* { box-sizing: border-box; margin: 0 }
+body { font-family: -apple-system, "SF Pro Text", sans-serif; }
+.wall { position: relative; overflow: hidden;
+ background: radial-gradient(1200px 700px at 15% 10%, #4f5d95 0%, transparent 60%),
+ radial-gradient(1000px 800px at 90% 100%, #8a4f7d 0%, transparent 55%),
+ linear-gradient(160deg, #1d2140, #2d2346 55%, #1b1f33); }
+.shadow { border-radius: 12px; box-shadow: 0 18px 50px rgba(0,0,0,.45), 0 0 0 .5px rgba(255,255,255,.12); }
+.menubar { position: absolute; top: 0; left: 0; right: 0; height: 26px; background: rgba(30,30,46,.55);
+ backdrop-filter: blur(20px); color: #e6e6ef; font-size: 13px; display: flex; align-items: center;
+ padding: 0 14px; gap: 18px; }
+.menubar .right { margin-left: auto; display: flex; gap: 16px; align-items: center; }
+.menubar .item { font-weight: 500; font-variant-numeric: tabular-nums; padding: 1px 7px; border-radius: 5px; background: rgba(255,255,255,.14) }
+.r { color: #f38ba8 } .t { color: #94e2d5 } .y { color: #f9e2af }
+.term { border-radius: 11px; overflow: hidden; }
+.term .bar { height: 30px; display: flex; align-items: center; gap: 8px; padding: 0 12px; font-size: 12.5px; }
+.term .dot { width: 12px; height: 12px; border-radius: 50%; }
+.term .title { flex: 1; text-align: center; margin-right: 52px; opacity: .7 }
+.term pre { font: 13px/1.38 "SF Mono", Menlo, monospace; padding: 10px 14px 6px; white-space: pre; }
+.b { font-weight: 700 } .d { opacity: .6 }
+/* Catppuccin Mocha */
+.dark { background: #1e1e2e; color: #cdd6f4 } .dark .bar { background: #181825; color: #cdd6f4 }
+.dark .c0{color:#45475a}.dark .c1{color:#f38ba8}.dark .c2{color:#a6e3a1}.dark .c3{color:#f9e2af}.dark .c4{color:#89b4fa}.dark .c5{color:#f5c2e7}.dark .c6{color:#94e2d5}.dark .c7{color:#bac2de}
+/* PaperColor Light */
+.light { background: #eeeeee; color: #4d4d4c } .light .bar { background: #e2e2e2; color: #4d4d4c }
+.light .c0{color:#ededed}.light .c1{color:#d7005f}.light .c2{color:#718c00}.light .c3{color:#d75f00}.light .c4{color:#4271ae}.light .c5{color:#8959a8}.light .c6{color:#3e999f}.light .c7{color:#4d4d4c}
+.dots { display: contents } .dot.a { background: #ff5f57 } .dot.b2 { background: #febc2e } .dot.c { background: #28c840 }
+.label { color: rgba(255,255,255,.7); font-size: 12px; margin: 0 0 7px 4px }
+.caption { color: rgba(255,255,255,.75); font-size: 13px; text-align: center; margin-top: 12px }
+EOF
+termwin() { # theme title
+ echo "<div class=\"term shadow $1\"><div class=\"bar\"><span class=\"dot a\"></span><span class=\"dot b2\"></span><span class=\"dot c\"></span><span class=\"title\">$2</span></div><pre>$TERM_HTML</pre></div>"
+}
+page() { # name width height body
+ printf '<!doctype html><meta charset="utf-8"><link rel="stylesheet" href="style.css"><body style="width:%spx;height:%spx">%s</body>' "$2" "$3" "$4" >"$WORK/$1.html"
+ rm -f "$WORK/$1-out.png"
+ "$CHROME" --headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=2 \
+ --window-size="$2,$3" --screenshot="$WORK/$1-out.png" "file://$WORK/$1.html" >/dev/null 2>&1
+ for _ in $(seq 100); do [ -s "$WORK/$1-out.png" ] && break; sleep 0.1; done
+ cp "$WORK/$1-out.png" "$OUT/$1.png"
+ echo "wrote docs/images/$1.png"
+}
+
+clock=$(date "+%a %-d %b %H:%M")
+menubar="<div class=\"menubar\"><b>Ghostty</b><span>Shell</span><span>Edit</span><span>View</span><span>Window</span>
+<div class=\"right\"><span class=\"item\"><span class=\"r\">×2</span> <span class=\"t\">✓3</span> <span class=\"y\">◐3</span></span><span>$clock</span></div></div>"
+page hero 1320 500 "<div class=\"wall\" style=\"width:1320px;height:500px\">$menubar
+<div style=\"position:absolute;left:30px;top:58px;width:822px\">$(termwin dark 'tmux — api')</div>
+<div class=\"panel shadow\" style=\"position:absolute;right:30px;top:42px;border-radius:12px\"></div></div>"
+
+page panel 1000 690 "<div class=\"wall\" style=\"width:1000px;height:690px;display:flex;gap:40px;padding:44px 40px;align-items:flex-start\">
+<div><div class=\"panel shadow\"></div><div class=\"caption\">Only when agents are waiting</div>
+<div class=\"panelrestore shadow\" style=\"margin-top:34px\"></div><div class=\"caption\">After a restart: restore everything</div></div>
+<div><div class=\"panelfull shadow\"></div><div class=\"caption\">Always, with the full list</div></div></div>"
+
+page search 700 360 "<div class=\"wall\" style=\"width:700px;height:360px;padding:50px 60px\"><div class=\"search shadow\" style=\"border-radius:14px\"></div></div>"
+
+page statusline 900 250 "<div class=\"wall\" style=\"width:900px;height:250px;padding:22px 30px\">
+<div class=\"label\">a dark theme (Catppuccin Mocha)</div><div class=\"term shadow dark\"><pre>$TERM_TAIL</pre></div>
+<div class=\"label\" style=\"margin-top:18px\">a light theme (PaperColor Light)</div><div class=\"term shadow light\"><pre>$TERM_TAIL</pre></div></div>"