aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 18:45:06 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 18:45:06 +0300
commitb4f45632debe6b8dc2f44ca6cc1b003d9093ad33 (patch)
tree550add91d02656156330f497d60fe0e6bdf2fc35
parent12ce9f3f8edc84064458b649e8615247641dad36 (diff)
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.
-rw-r--r--crates/dinggerd/src/snapshot.rs11
-rw-r--r--crates/dinggerd/src/tmux.rs28
-rw-r--r--crates/dinggerd/src/watcher.rs4
-rw-r--r--menubar/model.swift5
-rwxr-xr-xtests/e2e.sh41
5 files changed, 70 insertions, 19 deletions
diff --git a/crates/dinggerd/src/snapshot.rs b/crates/dinggerd/src/snapshot.rs
index d4ee321..715bf37 100644
--- a/crates/dinggerd/src/snapshot.rs
+++ b/crates/dinggerd/src/snapshot.rs
@@ -197,13 +197,22 @@ pub fn capture(now_ms: u64) -> Option<Snapshot> {
"#{@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
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 182d62f..6610161 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -15,10 +15,10 @@ pub struct Pane {
/// 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 struct Pane {
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 @@ fn run_tmux(args: &[&str]) -> Result<String, String> {
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 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{@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 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
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(),
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index 8951623..9198f60 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -267,7 +267,7 @@ impl Watcher {
// 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 @@ impl Watcher {
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;
}
diff --git a/menubar/model.swift b/menubar/model.swift
index a17b009..8bed858 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -228,9 +228,12 @@ func fetchRows() -> [Row] {
"#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
+ // A pane in several sessions (a session group, a linked window) is
+ // listed once, under its first session, as the daemon counts it.
+ var listed = Set<String>()
let rows: [Row] = out.split(separator: "\n").compactMap { line in
let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
- guard f.count >= 10, !f[1].isEmpty else { return nil }
+ guard f.count >= 10, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 670f84a..75dbc31 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -250,7 +250,10 @@ if [ -x "$MENUBAR" ]; then
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 sh
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
@@ -634,6 +638,41 @@ 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
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0