aboutsummaryrefslogtreecommitdiff
path: root/README.md
blob: d9ae7dea2f441a397b52287619a5d551a2d912ce (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# 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). 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 ⌃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.
- **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 bold `!` before its name; a low agent's name is
  lightly 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.

## 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`. Restart running pi agents to load it.
   `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`), so restarting
dingger loses nothing.

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

```bash
dingger watch                 # the daemon (run by launchd, see below)
dingger status                # agents in priority order
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 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

```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/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, 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`: 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.