session groups: list each pane once, save each group once a pane in several sessions (a session group, `new-session -t`, or a linked window) was listed once per session: every agent of a group showed twice in the menu, panel, search, `dingger status`, and the counts. and snapshots saved each session of a group with its own copy of the windows, so restore would have rebuilt them twice and resumed each agent twice on one session file. - a pane keeps its first listing (tmux lists sessions by name) and is attended if the user looks at it through any session of the group. - snapshots keep the first session of each group; the others are not restored. e2e step 19 covers both; the previous binaries fail it. two checks (step 11's and this one) waited for idle after a turn that started from idle, which passed before the turn was even processed: they now wait for working first, and the negative control for group attendance fails as it should. step 18 now waits for the snapshot it later restores from.
Igor Soarez igor@soarez.org
Fri, 23 Oct 2026 18:45:06 +0300
5 files changed,
70 insertions(+),
19 deletions(-)
M
crates/dinggerd/src/snapshot.rs
→
crates/dinggerd/src/snapshot.rs
@@ -197,13 +197,22 @@ "#{pane_current_command}",
"#{@agent_session}", "#{@dingger_cmd}", "#{pane_dead}", + "#{?session_grouped,#{session_group},}", ] .join("\t"); let out = tmux::run(&["list-panes", "-a", "-F", &format]).ok()?; let lines: Vec<Vec<&str>> = out .lines() .map(|line| line.split('\t').collect::<Vec<_>>()) - .filter(|f| f.len() >= 21) + .filter(|f| f.len() >= 22) + .collect(); + // One session per group: the others show the same windows, and saving + // them would restore every window, and resume every agent, twice. The + // first listed (tmux lists sessions by name) stands for its group. + let mut group_of_kept: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + let lines: Vec<Vec<&str>> = lines + .into_iter() + .filter(|f| f[21].is_empty() || *group_of_kept.entry(f[21]).or_insert(f[0]) == f[0]) .collect(); // What runs in the foreground of each pane's terminal. let pane_pids: Vec<u32> = lines
M
crates/dinggerd/src/tmux.rs
→
crates/dinggerd/src/tmux.rs
@@ -15,10 +15,10 @@ pub pane_title: String,
/// Working directory of the pane's foreground process. pub cwd: String, pub dead: bool, - /// The pane's window is the current window of its session. - pub window_active: bool, - /// The pane's session has at least one attached client. - pub session_attached: bool, + /// The user is looking at this pane's window: it is the current window + /// of a session with an attached client. herdr marks every pane of the + /// active tab seen (`mark_active_tab_seen`); a tmux window is a tab. + pub attended: bool, pub agent: Option<Agent>, /// Pane options written by dingger (see the watcher module docs). pub written_state: String,@@ -30,13 +30,6 @@ pub written_name: String,
pub written_session: String, } -impl Pane { - /// The user is looking at this pane's window. herdr marks every pane of - /// the active tab seen (`mark_active_tab_seen`); a tmux window is a tab. - pub fn attended(&self) -> bool { - self.window_active && self.session_attached - } -} /// The tmux binary. launchd starts dingger, and the menu bar app that runs /// `dingger focus`, with PATH=/usr/bin:/bin:/usr/sbin:/sbin, where Homebrew's@@ -92,6 +85,9 @@ }
Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +/// Every pane, once. A pane in several sessions (a session group, or a +/// linked window) keeps its first listing (tmux lists sessions by name), +/// and is attended if the user looks at it in any of them. pub fn list_panes() -> Result<Vec<Pane>, String> { let format = concat!( "#{pane_id}\t",@@ -115,13 +111,18 @@ "#{@agent_name}\t",
"#{@agent_session}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; - let mut panes = Vec::new(); + let mut panes: Vec<Pane> = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); if fields.len() < 19 { continue; } + let attended = fields[4] == "1" && fields[5] != "0"; + if let Some(listed) = panes.iter_mut().find(|pane| pane.pane_id == fields[0]) { + listed.attended |= attended; + continue; + } let current_command = fields[1]; let start_command = fields[6]; let pane_pid: Option<u32> = fields[7].trim().parse().ok();@@ -147,8 +148,7 @@ display_target,
pane_title: fields[2].to_string(), cwd: fields[16].to_string(), dead: fields[3] == "1", - window_active: fields[4] == "1", - session_attached: fields[5] != "0", + attended, agent, written_state: fields[8].to_string(), written_agent: fields[9].to_string(),
M
crates/dinggerd/src/watcher.rs
→
crates/dinggerd/src/watcher.rs
@@ -267,7 +267,7 @@ // Apply every observation in order: a turn that started and
// finished between two ticks is still a completion. let mut alerts = Vec::new(); for observed in observations { - let next = advance(track, observed, pane.attended(), acked, now); + let next = advance(track, observed, pane.attended, acked, now); if next.state != track.state { info!( target = %pane.display_target,@@ -338,7 +338,7 @@
fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) { // herdr: a blocked pane plays the alert even when the user is looking // at it; a finished pane stays silent when they already are. - let watching = pane.attended(); + let watching = pane.attended; if matches!(kind, NotificationKind::Finished) && watching { return; }
M
tests/e2e.sh
→
tests/e2e.sh
@@ -250,7 +250,10 @@ expect "A1 done" "opt $A1 @agent_state" done
expect "menu title" "mb --print | head -1" "title: ×1 ✓1" expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;" - hook "$B1" working; hook "$B1" idle + # Wait for working: B1 was idle before, so idle alone would not show the turn. + hook "$B1" working + expect "B1 working" "opt $B1 @agent_state" working + hook "$B1" idle expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle expect "and is now the most recent idle agent: listed first" \ "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"@@ -618,6 +621,7 @@ expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude
claude" 80 expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80 +expect "snapshot: the command line of the claude without a transcript" "in_snapshot '\"command\": \"claude fresh\"'" True 80 r kill-server expect "a new restore candidate" \ "python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80@@ -633,6 +637,41 @@ bash"
expect "the resumed claude reports its session again" \ "r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 [ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; } + +# --- 19. a session group: its panes once, its windows saved once ------------- +echo "19. session group" +start_viewer +r new-session -d -s grp -n agent -c "$W/one" +r new-window -d -t '=grp:' -n other -c "$W/two" +r new-session -d -t grp -s grp-2 +r send-keys -t '=grp:agent' 'pi' Enter +GA=$(r display -p -t '=grp:agent' '#{pane_id}') +expect "the group's agent is detected" "r show -pv -t $GA @agent 2>/dev/null" pi 80 +expect "dingger status lists it once" "renv \"$DINGGER\" status | grep -c ' grp'" 1 +expect "the menu lists it once" "mbr --print | grep -c 'grp'" 1 +# Watched only through grp-2: grp shows another window, nobody attaches it. +r select-window -t '=grp:other' +r select-window -t '=grp-2:agent' +r switch-client -c "$(r list-clients -F '#{client_name}' | head -1)" -t '=grp-2' +touch "$W/sessions/grp.jsonl" +# Wait for working: the agent was idle before, so idle alone would not show the turn. +hook2 "$GA" working "$W/sessions/grp.jsonl" +expect "the group's agent working" "r show -pv -t $GA @agent_state" working 80 +hook2 "$GA" idle "$W/sessions/grp.jsonl" +expect "a turn finished while watched through grp-2 is idle, not done" "r show -pv -t $GA @agent_state" idle 80 +expect "snapshot: the group is saved once" \ + "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"] if s[\"name\"].startswith(\"grp\")))' \"\$(latest_snapshot)\"" \ + "['grp']" 80 +expect "snapshot: with the agent's session" "in_snapshot '\"session\": \"$W/sessions/grp.jsonl\"'" True 80 +r kill-server +expect "the candidate: the group once" \ + "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"] if s[\"name\"].startswith(\"grp\")))' $STATE2/restore.json 2>/dev/null" "['grp']" 80 +expect "dry run plans the group once" "renv \"$DINGGER\" restore --dry-run 2>/dev/null | grep -E '^[a-z]+ +grp' | cut -c1-14" "create grp: " +: >"$DIR/pi-args" +renv "$DINGGER" restore --session grp >"$DIR/restore-grp.out" 2>&1 +expect "restore: the agent is resumed once" "grep -c -- '--session $W/sessions/grp.jsonl' $DIR/pi-args" 1 +expect "with both windows" "r list-windows -t '=grp' -F '#{window_name}' | tr '\n' ' '" "agent other " +expect "no real agent runs in a test pane (group)" "real_agents" "" # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null