all repos — dingger @ 50c433e82c368b9861a89deecb74e5f29fa50353

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

mark as unread: make an idle agent done again, to come back to it

like "mark as unread" in mail: an agent you have seen (idle) becomes done
again, last among done agents of its importance, with no sound or
notification. it stays done until you come back to it: leave its tmux
window and return, or jump to it. marked while you look at it, it holds
until you look away (`held`); a new turn clears it. only idle agents can
be marked.

- daemon: `pane.mark_unread` on the hook socket; the watcher applies it
  after the tick's observations (mark_unread, advance's hold rule).
- `dingger unread [--pane]`: refuses done, working, blocked, and panes
  without an agent; returns once the agent shows as done.
- pi: /unread (refused while the agent works or is blocked).
- panel: right-click an idle agent in the full list, "Mark as unread".
- search: ⌘U or right-click marks the selected idle agent; the footer
  offers ⌘U when it applies; search stays open.

tests: unit (hold, jump, new turn, idle only), e2e step 22 (queue
position, hold, look away and back, jump, refusals, no sound, menu, the
extension), pi-live in real pi 1.4 (a mark, not a turn: an unknown
command would also end done), swift selftest (menu only for idle, footer).
Igor Soarez igor@soarez.org
Tue, 27 Oct 2026 14:12:28 +0200
commit

50c433e82c368b9861a89deecb74e5f29fa50353

parent

9f3a567d4bb1b7b6e42a744936f993bcd437882d

M README.md → README.md

@@ -41,7 +41,8 @@ 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. + (`◐ 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

@@ -53,7 +54,8 @@ 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. With nothing typed, + 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.

@@ -106,6 +108,13 @@ restore, and its footer shows a level other than normal. For other agents,

run `dingger importance high|normal|low` in the agent's pane (in Claude Code: `! dingger importance high`), 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. ## Where state comes from

@@ -135,6 +144,7 @@ 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
M crates/dinggerd/assets/pi/pi-agent-state.ts → crates/dinggerd/assets/pi/pi-agent-state.ts

@@ -47,11 +47,12 @@ timeout.unref?.();

}); } -async function sendRequest(request: unknown): Promise<void> { +// Whether the daemon got it. +async function sendRequest(request: unknown): Promise<boolean> { if (await sendRequestAttempt(request, 500)) { - return; + return true; } - await sendRequestAttempt(request, 1500); + return sendRequestAttempt(request, 1500); } type AgentState = "working" | "blocked" | "idle";

@@ -96,9 +97,13 @@ }

return "normal"; } -function report(state: AgentState | undefined, message?: string): Promise<void> { - return sendRequest({ - id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, +function requestId(): string { + return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`; +} + +async function report(state: AgentState | undefined, message?: string): Promise<void> { + await sendRequest({ + id: requestId(), method: "pane.report_agent", params: { pane_id: paneId!,

@@ -223,6 +228,28 @@ void report(undefined);

} } ctx.ui.notify(`importance: ${importance}`, "info"); + }, + }); + + // Like "mark as unread" in mail: dingger shows the agent as done again + // (a pane option; nothing is saved in the session). + pi.registerCommand("unread", { + description: "Mark this agent unread in dingger: it shows as done until you come back to it", + handler: async (_args: string, ctx: any) => { + if (agentActive || blockedCount > 0) { + ctx.ui.notify("only an idle agent can be marked unread", "error"); + return; + } + const delivered = await sendRequest({ + id: requestId(), + method: "pane.mark_unread", + params: { pane_id: paneId! }, + }); + if (delivered) { + ctx.ui.notify("marked unread: dingger shows it as done until you come back to it", "info"); + } else { + ctx.ui.notify("dingger is not running", "error"); + } }, });
M crates/dinggerd/src/hooks.rs → crates/dinggerd/src/hooks.rs

@@ -18,6 +18,9 @@ //! Reports are state-change *events*: the watcher applies every one in

//! order, so a turn that starts and finishes between two polls is still a //! completion, and each report wakes the watcher immediately. //! +//! `{"method":"pane.mark_unread","params":{"pane_id":"%3"}}` marks an idle +//! agent unread (`dingger unread`, pi's /unread): it shows as done again. +//! //! Each line is answered with `{"ok":true}`. //! //! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's

@@ -26,7 +29,7 @@ //! next poll. The last reported state stays authoritative for its pane

//! until the next report or until the pane stops running the agent; screen //! detection is the fallback for panes that never report. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{BufRead, BufReader}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf;

@@ -66,6 +69,20 @@ REPORTS

.get_or_init(|| Mutex::new(HashMap::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Panes marked unread, not yet applied by the watcher. +fn unread_marks() -> MutexGuard<'static, HashSet<String>> { + static MARKS: OnceLock<Mutex<HashSet<String>>> = OnceLock::new(); + MARKS + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Whether the pane was marked unread since the last call. +pub fn take_unread(pane_id: &str) -> bool { + unread_marks().remove(pane_id) } fn wake_signal() -> &'static (Mutex<bool>, Condvar) {

@@ -143,6 +160,14 @@ continue;

}; if message.method == "poke" { wake(); + continue; + } + if message.method == "pane.mark_unread" { + if let Some(params) = message.params { + info!(pane = %params.pane_id, "marked unread"); + unread_marks().insert(params.pane_id); + wake(); + } continue; } let (true, Some(params)) = (message.method == "pane.report_agent", message.params) else {
M crates/dinggerd/src/main.rs → crates/dinggerd/src/main.rs

@@ -82,6 +82,14 @@ /// The agent's pane (default: the pane this runs in).

#[arg(long)] pane: Option<String>, }, + /// Mark an idle agent unread: it shows as done again, last among done + /// agents of its importance, until you come back to it. In pi, /unread + /// does the same. + Unread { + /// The agent's pane (default: the pane this runs in). + #[arg(long)] + pane: Option<String>, + }, /// Restore tmux sessions saved before tmux last stopped (a crash, /// shutdown, or kill-server): windows, panes, layouts, directories. /// Only adds what is missing; a bare session of the same name (what a

@@ -190,6 +198,12 @@ }

} Command::Importance { level, pane } => { if let Err(err) = set_importance(level.as_deref(), pane) { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } + Command::Unread { pane } => { + if let Err(err) = mark_unread(pane) { eprintln!("dingger: {err}"); std::process::exit(1); }

@@ -339,6 +353,43 @@ });

hooks::send(&message.to_string())?; println!("importance: {}", level.label()); Ok(()) +} + +/// `dingger unread`: ask the daemon to mark the pane's idle agent unread, +/// and wait until it shows as done. +fn mark_unread(pane: Option<String>) -> Result<(), String> { + let target = pane + .or_else(|| std::env::var("TMUX_PANE").ok()) + .ok_or("not in a tmux pane; pass --pane")?; + let read = || -> Result<(String, String, String), String> { + let info = tmux::run(&["display-message", "-p", "-t", &target, "#{pane_id}\t#{@agent}\t#{@agent_state}"]) + .map_err(|_| format!("no pane {target}"))?; + match info.trim_end_matches('\n').split('\t').collect::<Vec<_>>()[..] { + [id, agent, state] => Ok((id.to_string(), agent.to_string(), state.to_string())), + _ => Err(format!("no pane {target}")), + } + }; + let (pane_id, agent, state) = read()?; + match (agent.as_str(), state.as_str()) { + ("", _) => return Err(format!("no agent in pane {target}")), + (_, "idle") => {} + (_, "done") => return Err("it is unread already (done)".into()), + (_, state) => return Err(format!("only an idle agent can be marked unread; this one is {state}")), + } + let message = serde_json::json!({ "method": "pane.mark_unread", "params": { "pane_id": pane_id } }); + hooks::send(&message.to_string())?; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < deadline { + match read()?.2.as_str() { + "done" => { + println!("marked unread"); + return Ok(()); + } + "idle" => std::thread::sleep(std::time::Duration::from_millis(50)), + other => return Err(format!("not marked: it turned {other}")), + } + } + Err("not marked: the daemon did not apply it".into()) } /// Every agent pane in herdr's priority order (blocked, done, working,
M crates/dinggerd/src/watcher.rs → crates/dinggerd/src/watcher.rs

@@ -105,6 +105,9 @@ /// Unix millisecond the pane entered its current status: the queue

/// serves done and blocked oldest first; working and idle are listed /// most recent first. pub since: Option<u64>, + /// Marked unread while the user looked at it: it stays done until they + /// look away, and is seen when they come back. + pub held: bool, } impl PaneTrack {

@@ -113,6 +116,7 @@ pub const NEW: PaneTrack = PaneTrack {

state: AgentState::Unknown, seen: true, since: None, + held: false, }; pub fn status(&self) -> Status {

@@ -135,7 +139,7 @@ "working" => (AgentState::Working, true),

"blocked" => (AgentState::Blocked, true), _ => return PaneTrack::NEW, }; - PaneTrack { state, seen, since } + PaneTrack { state, seen, since, held: false } } }

@@ -146,7 +150,9 @@ /// - Transition to any non-idle state: seen.

/// - Completion (working/blocked to idle): seen only if the user is looking /// at the pane's window; otherwise the pane is done. /// - Any other transition to idle (first observation): seen unchanged. -/// - Looking at the pane's window, or jumping to it (`acked`): seen. +/// - Looking at the pane's window, or jumping to it (`acked`): seen. A pane +/// marked unread while looked at (`held`) is seen only once the user +/// looks away and back, or jumps to it. pub fn advance( prev: PaneTrack, observed: AgentState,

@@ -157,19 +163,33 @@ ) -> PaneTrack {

let mut next = prev; if observed != prev.state { next.state = observed; + next.held = false; if observed != AgentState::Idle { next.seen = true; } else if matches!(prev.state, AgentState::Working | AgentState::Blocked) { next.seen = attended; } } - if attended || acked { + if acked || (attended && !next.held) { next.seen = true; + next.held = false; + } else if !attended { + next.held = false; } if next.status() != prev.status() || next.since.is_none() { next.since = Some(now); } next +} + +/// Mark an idle agent unread: done again, listed last among done agents of +/// its importance (from now). If the user is looking at it, it stays done +/// until they look away. Any other status is left alone. +pub fn mark_unread(track: PaneTrack, attended: bool, now: u64) -> PaneTrack { + if track.status() != Status::Idle { + return track; + } + PaneTrack { seen: false, held: attended, since: Some(now), ..track } } /// The order agents are listed in: herdr's status priority; within a

@@ -296,6 +316,16 @@ if !restored {

alerts.extend(notification_kind_for_transition(track.state, next.state)); } } + track = next; + } + if hooks::take_unread(&pane.pane_id) { + let next = mark_unread(track, pane.attended, now); + info!( + target = %pane.display_target, + applied = next != track, + status = track.status().label(), + "mark unread" + ); track = next; } self.tracks.insert(pane.pane_id.clone(), track);

@@ -559,6 +589,49 @@ assert_eq!(still_done.status(), Status::Done);

let seen = advance(still_done, Idle, true, false, 170); assert_eq!(seen.status(), Status::Idle); assert_eq!(seen.since, Some(170), "idle since the user looked"); + } + + fn seen_while_looking() -> PaneTrack { + step(step(PaneTrack::NEW, Working, true), Idle, true) + } + + #[test] + fn marking_unread_while_away_waits_for_the_user_to_look() { + let idle = seen_while_looking(); + let marked = mark_unread(idle, false, 300); + assert_eq!(marked.status(), Status::Done); + assert_eq!(marked.since, Some(300), "last among done agents"); + assert_eq!(step(marked, Idle, false).status(), Status::Done); + assert_eq!(step(marked, Idle, true).status(), Status::Idle, "seen when looked at"); + } + + #[test] + fn marking_unread_while_looking_holds_until_the_user_looks_away_and_back() { + let marked = mark_unread(seen_while_looking(), true, 300); + let still = step(step(marked, Idle, true), Idle, true); + assert_eq!(still.status(), Status::Done, "still looking: stays done"); + let away = step(still, Idle, false); + assert_eq!(away.status(), Status::Done); + assert_eq!(step(away, Idle, true).status(), Status::Idle, "back: seen"); + } + + #[test] + fn a_held_mark_clears_on_a_jump_or_a_new_turn() { + let marked = mark_unread(seen_while_looking(), true, 300); + assert_eq!(advance(marked, Idle, true, true, 310).status(), Status::Idle, "jumped to it"); + let working = step(marked, Working, true); + assert_eq!(working.status(), Status::Working); + assert_eq!(step(working, Idle, true).status(), Status::Idle, "finished while looking"); + } + + #[test] + fn only_an_idle_agent_can_be_marked_unread() { + let working = step(PaneTrack::NEW, Working, false); + assert_eq!(mark_unread(working, false, 300), working); + let done = step(working, Idle, false); + assert_eq!(mark_unread(done, false, 300), done, "done keeps its place"); + let blocked = step(PaneTrack::NEW, Blocked, false); + assert_eq!(mark_unread(blocked, true, 300), blocked); } // herdr: agent_focus_marks_already_focused_done_agent_seen
M tests/e2e.sh → tests/e2e.sh

@@ -930,6 +930,65 @@ expect "normal reaches dingger" "opt $I2 @dingger_importance" ""

expect "and leaves the footer" "grep -c '^status dingger-importance $' $EXTLOG" 1 fi +# --- 22. mark as unread ---------------------------------------------------- +# An idle agent marked unread is done again, last among done agents of its +# importance, until the user comes back to it. +echo "22. mark as unread" +t new-session -d -s unr -n u1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" +t new-window -d -t unr: -n u2 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t unr: -n u3 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t unr: -n away -c "$DIR" "sleep 100000" +U1=$(t display -p -t unr:u1 '#{pane_id}'); U2=$(t display -p -t unr:u2 '#{pane_id}') +U3=$(t display -p -t unr:u3 '#{pane_id}'); AWAY=$(t display -p -t unr:away '#{pane_id}') +uqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$U1|$U2|$U3" | tr '\n' ' '; } +look() { t switch-client -c "$CLIENT" -t "$1"; $DINGGER poke; } +look "$AWAY" +report "$U3" "" low +for p in "$U3" "$U1"; do report "$p" working ""; report "$p" idle ""; expect "unr: $p done" "opt $p @agent_state" done; sleep 0.05; done +look "$U1" +expect "unr: looking at u1 sees it" "opt $U1 @agent_state" idle +look "$AWAY" +# u2 finishes after u1 was seen: by its old time, u1 would come first. +report "$U2" working ""; report "$U2" idle ""; expect "unr: $U2 done" "opt $U2 @agent_state" done +expect "unr: the queue without it" "uqueue" "$U2 $U3 " +look "$U1" +sleep 3.5; : >"$SOUNDS"; : >"$NOTES" +expect "dingger unread marks it" "$DINGGER unread --pane $U1" "marked unread" +expect "done again" "opt $U1 @agent_state" done +expect "last among done agents of its importance, before a low one" "uqueue" "$U2 $U1 $U3 " +expect "menu: shown as done" "mb --print | grep -F 'unr · u1' | cut -d' ' -f1" "✓" +$DINGGER poke; sleep 1; $DINGGER poke +expect "still looking at it: still done" "opt $U1 @agent_state" done +sleep 1 +expect "marking makes no sound" "cat $SOUNDS" "" +expect "and no notification" "cat $NOTES" "" +look "$AWAY" +expect "looking away: still done" "opt $U1 @agent_state" done 10 +look "$U1" +expect "coming back sees it" "opt $U1 @agent_state" idle +expect "unr: marked again" "$DINGGER unread --pane $U1" "marked unread" +$DINGGER focus --client "$CLIENT" --pane "$U1" +expect "a jump to it sees it" "opt $U1 @agent_state" idle +look "$AWAY" +expect "marked while away" "$DINGGER unread --pane $U1" "marked unread" +look "$U1" +expect "seen on arrival" "opt $U1 @agent_state" idle +expect "a done agent is refused" "$DINGGER unread --pane $U2 2>&1" "dingger: it is unread already (done)" +report "$U3" working ""; expect "unr: u3 working" "opt $U3 @agent_state" working +expect "a working agent is refused" "$DINGGER unread --pane $U3 2>&1" \ + "dingger: only an idle agent can be marked unread; this one is working" +expect "a pane without an agent is refused" "$DINGGER unread --pane $AWAY 2>&1" "dingger: no agent in pane $AWAY" +if command -v node >/dev/null; then + : >"$EXTLOG"; iext "$U1" session_start "/unread" + expect "/unread marks it" "opt $U1 @agent_state" done + expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1 + look "$AWAY"; look "$U1" + expect "unr: seen again" "opt $U1 @agent_state" idle + : >"$EXTLOG"; iext "$U1" session_start agent_start "/unread" + expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1 + expect "and changes nothing" "opt $U1 @agent_state" working +fi + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
M tests/pi-live.sh → tests/pi-live.sh

@@ -7,7 +7,7 @@ #

# tests/pi-live.sh <pi binary> [dingger binary] # # Covers: identification, the extension's state and session reports, a turn -# (working, then done), /reload, an extension's blocked event, /importance, +# (working, then done), /reload, an extension's blocked event, /importance, /unread, # screen detection for a pi without the extension, restore (`pi --session`, # importance included), exit. set -u

@@ -112,6 +112,15 @@ type_line agent "/importance high"

expect "/importance high: the pane option" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50 expect "and pi's footer (its last line)" "t capture-pane -p -t main:agent | grep -v '^ *$' | tail -1 | sed 's/^ *//'" "importance: high" 30 expect "saved in the session file" "grep -c '\"customType\":\"dingger-importance\"' \"\$(t show -pv -t main:agent @agent_session)\"" 1 30 +# Seen (what a jump does; no client is attached here), then marked unread. +t set -p -t main:agent @dingger_ack 1; "$DINGGER" poke +expect "seen: idle" "st agent" idle 50 +type_line agent "/unread" +expect "/unread: done again" "st agent" done 50 +# Not a turn (an unknown command would be sent to the model, and also end +# done): the daemon applied a mark, and pi said so. +expect "by a mark" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'mark unread target=main:agent applied=true'" 1 30 +expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'marked unread: dingger shows it as done'" 1 30 echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter