#!/usr/bin/env bash # End-to-end test of dingger's status model against a real, isolated tmux # server. Nothing here touches the user's tmux server or hook socket. # # tests/e2e.sh [path/to/dingger] # # The test daemon polls only every 30s, so every update asserted here must # arrive through events (hook reports, focus pokes, tmux focus hooks), the # same paths that make the real UI react immediately. # # Layout: # session a: window wa1 (pane A1, fake pi), window wa2 (pane A2, fake pi) # session b: window wb1 (pane B1, fake pi) # session viewer: a pane running a real tmux client attached to session b, # so "what the user is looking at" is real tmux state. set -u ROOT=$(cd "$(dirname "$0")/.." && pwd) DINGGER="${1:-$ROOT/target/release/dingger}" MENUBAR="${2:-$ROOT/target/dingger-menu}" # tmux hooks run these from other directories: make them absolute. case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac case "$MENUBAR" in /*) ;; *) MENUBAR="$PWD/$MENUBAR" ;; esac SOCK=dingger-e2e DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX) HOOK="$DIR/agent.sock" FAILS=0 PASSES=0 # Every dingger invocation below (daemon, focus, poke, menu bar) targets the # isolated server and the test daemon's hook socket. export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state" t() { tmux -u -L "$SOCK" "$@"; } cleanup() { [ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null [ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null t kill-server 2>/dev/null tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null rm -rf "$DIR" } trap cleanup EXIT # Notifications are on, delivered by a fake notifier that takes 3s like the # real one can; the UI must not wait for it. NOTES="$DIR/notifications" start_daemon() { DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \ "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 & DAEMON_PID=$! for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1 } hook() { # pane state python3 - "$HOOK" "$1" "$2" <<'PY' import json, socket, sys s = socket.socket(socket.AF_UNIX) s.connect(sys.argv[1]) s.sendall((json.dumps({"method": "pane.report_agent", "params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3]}}) + "\n").encode()) s.close() PY } opt() { t show -pv -t "$1" "$2" 2>/dev/null; } # pane option gopt() { t show -gv "$1" 2>/dev/null; } # global option client_window() { t display -p -c "$CLIENT" '#{window_name}'; } pass() { PASSES=$((PASSES + 1)); echo " ok $1"; } fail() { FAILS=$((FAILS + 1)); echo " FAIL $1"; } expect() { # description command expected [tries] (polls every 0.1s, default 4s) local desc=$1 cmd=$2 want=$3 tries=${4:-40} got for _ in $(seq "$tries"); do got=$(eval "$cmd") [ "$got" = "$want" ] && { pass "$desc"; return; } sleep 0.1 done fail "$desc: want [$want] got [$got]" } # --- setup ------------------------------------------------------------------- mkdir -p "$DIR/bin" printf '#!/bin/sh\nexec sleep 100000\n' >"$DIR/bin/pi" chmod +x "$DIR/bin/pi" t -f /dev/null new-session -d -s a -n wa1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t new-window -d -t a: -n wa2 -c "$DIR" "$DIR/bin/pi" t new-session -d -s b -n wb1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t new-session -d -s c -n wc1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" t set -g mouse on A1=$(t display -p -t a:wa1 '#{pane_id}') A2=$(t display -p -t a:wa2 '#{pane_id}') B1=$(t display -p -t b:wb1 '#{pane_id}') C1=$(t display -p -t c:wc1 '#{pane_id}') # pi titles its pane "π - - ", or "π - " D=$(basename "$DIR") t select-pane -t "$A1" -T "π - Deploy the widget - $D" t select-pane -t "$A2" -T "π - $D" t select-pane -t "$B1" -T "π - Review PR 42 - $D" t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b" for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done [ -z "$CLIENT" ] && { echo "no client attached"; exit 1; } echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)" # tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\"" [ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done start_daemon # --- 1. first observation is idle, not done (herdr: initial idle stays seen) -- echo "1. first observation" for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done expect "A1 idle on first sight" "opt $A1 @agent_state" idle expect "A2 idle on first sight" "opt $A2 @agent_state" idle expect "B1 idle on first sight" "opt $B1 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" expect "A1 name from pi's title" "opt $A1 @agent_name" "Deploy the widget" expect "A2 unnamed pi session has no name" "opt $A2 @agent_name" "" expect "B1 name from pi's title" "opt $B1 @agent_name" "Review PR 42" # --- 2. working, then completion away from the window -> done ---------------- echo "2. completion while away" hook "$A1" working expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5 expect "working count 1" "gopt @dingger_working" 1 hook "$A1" idle expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5 expect "done count 1" "gopt @dingger_done" 1 expect "queue = A1" "gopt @dingger_queue" "$A1" expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60 # --- 3. completion in the window the user is looking at -> idle -------------- echo "3. completion while looking" hook "$B1" working expect "B1 working" "opt $B1 @agent_state" working hook "$B1" idle expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle expect "queue still = A1" "gopt @dingger_queue" "$A1" sleep 3.5 expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0 # --- 4. FIFO and blocked ------------------------------------------------------- echo "4. fifo and blocked" hook "$A2" working hook "$A2" blocked expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5 expect "blocked count 1" "gopt @dingger_blocked" 1 expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60 expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1" hook "$C1" working; hook "$C1" idle expect "C1 done" "opt $C1 @agent_state" done expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1" expect "client shows wc1" "client_window" wc1 expect "C1 idle after the jump" "opt $C1 @agent_state" idle # --- 5. attending by navigating --------------------------------------------- echo "5. navigate to a done agent" t switch-client -c "$CLIENT" -t "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10 expect "queue = A2" "gopt @dingger_queue" "$A2" expect "done count 0" "gopt @dingger_done" 0 # --- 6. status-line click: focus with no args jumps to the queue head -------- echo "6. dingger focus (status-line click)" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus expect "client jumped to wa2" "client_window" wa2 expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked expect "ack consumed" "opt $A2 @dingger_ack" "" hook "$A2" working hook "$A2" idle expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" # --- 7. menu row click: focus --pane ----------------------------------------- echo "7. dingger focus --pane (menu row click)" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working hook "$A1" idle expect "A1 done again" "opt $A1 @agent_state" done DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle after the jump" "opt $A1 @agent_state" idle # --- 8. a whole turn between two polls is still a completion ---------------- echo "8. sub-second turn" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle # both within one poll interval expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done t switch-client -c "$CLIENT" -t "$A1" expect "A1 idle once looked at" "opt $A1 @agent_state" idle # --- 9. restart keeps state ----------------------------------------------------- echo "9. daemon restart" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" working hook "$A2" idle expect "A2 done before restart" "opt $A2 @agent_state" done kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null start_daemon sleep 1.5 expect "A2 still done after restart" "opt $A2 @agent_state" done expect "A1 still idle after restart" "opt $A1 @agent_state" idle expect "B1 still idle after restart" "opt $B1 @agent_state" idle expect "queue = A2 after restart" "gopt @dingger_queue" "$A2" hook "$A2" working expect "hook state still authoritative after restart" "opt $A2 @agent_state" working # --- 10. the tmux status-line segment and its click binding ----------------- echo "10. status line" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" blocked hook "$A1" working; hook "$A1" idle expect "A2 blocked" "opt $A2 @agent_state" blocked expect "A1 done" "opt $A1 @agent_state" done t source-file "$ROOT/packaging/tmux/dingger.tmux" segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 expect "focus hook installed: learns the terminal window" "t show-hooks -g client-focus-in | grep -c 'dingger terminal focused'" 1 expect "with the terminal's focus reports" "t show -gv focus-events" on expect "clicking the restore offer runs restore" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'restore --report'" 1 t set -g @dingger_restore 3 segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') case "$segment" in "↺3 "*"×1"*) pass "segment offers to restore 3 sessions ($segment)";; *) fail "restore segment: [$segment]";; esac t set -gu @dingger_restore # same run-shell + #{client_name} expansion as the mouse binding, via a key t bind -n F12 run-shell "DINGGER_TMUX_SOCKET=$SOCK $DINGGER focus --client '#{client_name}'" t send-keys -t viewer: F12 expect "key binding jumped to the top waiter (A2, blocked)" "client_window" wa2 t send-keys -t viewer: F12 expect "next press jumps to the next waiter (A1)" "client_window" wa1 expect "A1 idle once jumped to" "opt $A1 @agent_state" idle # --- 11. the menu bar binary: rendering and row click ------------------------ echo "11. menu bar" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle 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;" # 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;" expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now" expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi" mb --click "$A1" expect "row click jumped to wa1" "client_window" wa1 expect "row click marked A1 idle" "opt $A1 @agent_state" idle expect "menu title after the click" "mb --print | head -1" "title: ×1" else fail "menu bar binary not found at $MENUBAR" fi # --- 12. the shipped pi extension reports for its own pane ------------------- echo "12. pi extension" cat >"$DIR/ext-harness.mjs" <<'JS' // Loads the shipped extension with a fake pi and fires lifecycle events. const ext = await import(process.env.EXTENSION); const on = {}; ext.default({ on: (name, fn) => (on[name] = fn), events: { on: () => {} } }); const ctx = { mode: "tui", isIdle: () => true }; for (const event of process.argv.slice(2)) { await on[event]?.({ type: event, reason: "reload" }, ctx); await new Promise((r) => setTimeout(r, 300)); } JS ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there) local pane=$1; shift TMUX_PANE="$pane" EXTENSION="$ROOT/crates/dinggerd/assets/pi/pi-agent-state.ts" \ node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@" } if command -v node >/dev/null; then # A2 is not the active pane of session a (wa1 is the active window), the # case where asking tmux for "the current pane" picks the wrong pane. [ "$(t display -p -t a: '#{pane_id}')" != "$A2" ] && pass "A2 is not session a's active pane" || fail "test setup: A2 is active" before_a1=$(opt "$A1" @agent_state) ext "$A2" session_start agent_start expect "extension report lands on A2 (working)" "opt $A2 @agent_state" working expect "A1 untouched" "opt $A1 @agent_state" "$before_a1" ext "$A2" agent_settled expect "extension completion away from the window: A2 done" "opt $A2 @agent_state" done else fail "node not found; cannot test the pi extension" fi # --- 13. the floating panel: visibility rules and compact content ----------- echo "13. floating panel" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } expect "panel visibility rules (selftest)" "mb --selftest | tail -1" "selftest ok" expect "panel lists the waiting agent" "mb --print-panel | head -1 | cut -f1,2 | tr '\t' '|'" "✓ done|pi" expect "panel summarizes the rest" "mb --print-panel | tail -1" "○ 3 idle" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A2" expect "A2 idle after the jump" "opt $A2 @agent_state" idle expect "panel with nothing waiting" "mb --print-panel" "Nothing waiting · ○ 4 idle" # Most recent first: A2 (just jumped to), A1 (clicked in step 11), B1 (finished # in step 11, before that click), C1. expect "full list: every agent, then the summary with the toggle" \ "mb --print-panel --full | cut -f1,2 | tr '\t\n' '|;'" \ "○ idle|pi;○ idle|Deploy the widget;○ idle|Review PR 42;○ idle|pi;Nothing waiting · ○ 4 idle|[Show less ▴];" expect "compact in Always mode offers 'Show all'" "mb --print-panel --toggle" "Nothing waiting · ○ 4 idle [Show all ▾]" hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done expect "full list separates waiting from the rest" "mb --print-panel --full | sed -n 2p" "---" else fail "menu bar binary not found at $MENUBAR" fi # --- 14. clicks work with launchd's environment ------------------------------ # launchd starts the menu bar app with PATH=/usr/bin:/bin:/usr/sbin:/sbin, # which has no Homebrew tmux; every click runs `dingger focus` from there. echo "14. launchd environment" launchd_env() { env -i HOME="$HOME" PATH=/usr/bin:/bin:/usr/sbin:/sbin DINGGER_TMUX_SOCKET="$SOCK" \ DINGGER_AGENT_SOCKET="$HOOK" DINGGER_BIN="$DINGGER" "$@" } t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle expect "A1 done" "opt $A1 @agent_state" done if [ -x "$MENUBAR" ]; then launchd_env "$MENUBAR" --click "$A1" 2>"$DIR/click.err" expect "menu bar row click jumps, under launchd's PATH" "client_window" wa1 expect "A1 idle after that click" "opt $A1 @agent_state" idle fi t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" working; hook "$A2" idle expect "A2 done" "opt $A2 @agent_state" done launchd_env "$DINGGER" focus expect "dingger focus (jump shortcut) jumps, under launchd's PATH" "client_window" wa2 # --- 15. agent search ---------------------------------------------------------- echo "15. agent search" if [ -x "$MENUBAR" ]; then mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } expect "empty query lists every agent" "mb --print-search '' | tail -1 | cut -d' ' -f1,2" "4 agents" expect "fuzzy name match, selected" "mb --print-search 'rvw42' | head -1 | cut -f1,2" "› ○ idle Review PR 42" expect "every word must match" "mb --print-search 'deploy 42' | head -1" "No agents match 'deploy 42'" expect "finds an agent by its window" "mb --print-search wc1 | head -1 | cut -f3" "c · wc1 · pi" expect "finds agents by directory" "mb --print-search \"$(basename "$DIR")\" | tail -1 | cut -d' ' -f1,2" "4 agents" hook "$A2" working expect "status word ranks first" "mb --print-search working | head -1 | cut -f1" "› ◐ working" else fail "menu bar binary not found at $MENUBAR" fi # --- 16. restore after the tmux server goes away ------------------------------ # A second server and daemon. "viewer" hosts a client the way a terminal # does: Ghostty runs `tmux new-session -As 0`. echo "16. restore" R="$SOCK-restore-$$" STATE2="$DIR/state2" # tmux gives new windows the environment of the client that creates them: # both the test and restore run with the fake pi first on PATH. r() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" "$@"; } # A server restore starts gets the same plain shells as the test's own: the # user's login shell would read their profile, which puts the real agents # first on PATH. printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$DIR/tmux-test.conf" renv() { env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" "$@"; } # Every pi or claude in a test pane must be a fake (they run from $DIR). real_agents() { for tty in $(r list-panes -a -F '#{pane_tty}' 2>/dev/null | sed 's|/dev/||'); do ps -o comm=,args= -t "$tty" | awk -v dir="$DIR/" '($1 ~ /(^|\/)(pi|claude)$/ || $2 ~ /^(pi|claude)$/) && index($2, dir) != 1' done } W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three" # A fake pi named pi (the daemon finds agents by process name) that records # its arguments. mkdir -p "$DIR/rbin/real" cp /bin/sleep "$DIR/rbin/real/pi" && codesign --force --sign - "$DIR/rbin/real/pi" 2>/dev/null printf '#!/bin/sh\necho "$*" >> %s\nexec %s 100000\n' "$DIR/pi-args" "$DIR/rbin/real/pi" >"$DIR/rbin/pi" chmod +x "$DIR/rbin/pi" start_viewer() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \ set -g default-command /bin/sh \; \ send-keys -t viewer "exec env -u TMUX tmux -u -L $R new-session -As 0 -c $W/one" Enter for _ in $(seq 50); do r has-session -t =0 2>/dev/null && [ -n "$(r list-clients)" ] && return; sleep 0.1; done echo "viewer client did not attach" } start_viewer P0=$(r display -p -t '=0:' '#{pane_id}') r rename-window -t "$P0" edit P1=$(r split-window -h -t "$P0" -c "$W/two" -P -F '#{pane_id}') r split-window -v -t "$P1" -c "$W/three" r resize-pane -t "$P0" -x 30 r new-window -d -t '=0:5' -c "$W/two" r split-window -d -t '=0:5' -c "$W/three" r resize-pane -Z -t '=0:5.1' r select-window -t '=0:edit' r new-session -d -s work -n grid -c "$W/three" -x 100 -y 30 for d in one two three; do r split-window -d -t '=work:grid' -c "$W/$d"; r select-layout -t '=work:grid' tiled; done r select-pane -t '=work:grid.2' r new-window -d -t '=work:7' -n logs -c "$W/one" r select-window -t '=work:7' r new-session -d -s keep -n saved -c "$W/one" r new-window -d -t '=keep:' -n saved2 # Agents, a running command, and a command the zsh hook recorded. r new-window -d -t '=work:3' -n agent -c "$W/two" r new-window -d -t '=work:4' -n gone -c "$W/one" r new-window -d -t '=work:5' -n cmd -c "$W/one" r new-window -d -t '=work:6' -n hooked -c "$W/one" # Never start the real pi: the panes must find the fake one first. r send-keys -t '=work:agent' "command -v pi > $DIR/which-pi" Enter for _ in $(seq 30); do [ -s "$DIR/which-pi" ] && break; sleep 0.1; done if [ "$(cat "$DIR/which-pi" 2>/dev/null)" != "$DIR/rbin/pi" ]; then fail "the restore server's panes would run $(cat "$DIR/which-pi" 2>/dev/null), not the fake pi; stopping" echo "$PASSES passed, $FAILS failed"; exit 1 fi r send-keys -t '=work:agent' 'clear; pi' Enter r send-keys -t '=work:gone' 'pi' Enter r send-keys -t '=work:cmd' "sh -c 'sleep 100000; :' 'two words'" Enter r send-keys -t '=work:hooked' 'sleep 200000' Enter fingerprint() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{window_active}|#{window_zoomed_flag}|#{automatic-rename}|#{pane_index}|#{pane_active}|#{pane_left},#{pane_top},#{pane_width}x#{pane_height}|#{pane_current_path}' \ | grep -E '^(0|work)\|' } BEFORE=$(fingerprint) # Started directly, not through a function: $! must be the daemon itself. env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" \ "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 & DAEMON2_PID=$! latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; } expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80 hook2() { # pane state session-file python3 - "$DIR/hook2.sock" "$1" "$2" "$3" <<'PY' import json, socket, sys s = socket.socket(socket.AF_UNIX) s.connect(sys.argv[1]) s.sendall((json.dumps({"method": "pane.report_agent", "params": {"pane_id": sys.argv[2], "agent": "pi", "state": sys.argv[3], "agent_session_path": sys.argv[4]}}) + "\n").encode()) s.close() PY } AGENT=$(r display -p -t '=work:agent' '#{pane_id}') GONE=$(r display -p -t '=work:gone' '#{pane_id}') expect "the fake pi agents are detected" "r show -pv -t $AGENT @agent 2>/dev/null; r show -pv -t $GONE @agent 2>/dev/null" "pi pi" 80 # Session files exist: restore resumes only sessions with one. mkdir -p "$W/sessions" && touch "$W/sessions/a b.jsonl" "$W/sessions/gone.jsonl" hook2 "$AGENT" working "$W/sessions/a b.jsonl" hook2 "$GONE" idle "$W/sessions/gone.jsonl" r set-option -p -t '=work:hooked' @dingger_cmd 'slp 200000 | cat' expect "an agent's session file is kept on its pane" "r show -pv -t $AGENT @agent_session 2>/dev/null" "$W/sessions/a b.jsonl" in_snapshot() { python3 -c 'import sys; print(sys.argv[2] in open(sys.argv[1]).read())' "$(latest_snapshot)" "$1"; } expect "snapshot: the agent's session" "in_snapshot '\"session\": \"$W/sessions/a b.jsonl\"'" True 80 expect "snapshot: a running command's exact arguments" "in_snapshot \"'sleep 100000; :' 'two words'\"" True 80 expect "snapshot: the zsh hook's text" "in_snapshot '\"command\": \"slp 200000 | cat\"'" True 80 # An agent that exits just before tmux dies (a shutdown stops programs first). kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')" expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" "" sleep 1.5 expect "snapshot: the exited agent is kept for a while" "in_snapshot gone.jsonl" True r kill-server expect "the dead server's layout becomes the restore candidate" \ "python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80 expect "the last snapshot is kept while tmux is down" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\"" 1 start_viewer r new-session -d -s keep -n mine "sleep 100000" expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}' | tr '\n' ' '" "1 " expect "the candidate survives the new server's snapshots" \ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80 expect "dry run plans: replace 0, keep keep and viewer, create work" \ "renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;" mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \ DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; } expect "the daemon offers to restore 0 and work" \ "python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \ "['0', 'work'] 2 1" 80 expect "and tells the status line" "r show -gv @dingger_restore" 2 expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]" expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn" mbr --restore cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0 expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl --session $W/sessions/gone.jsonl" expect "no real agent runs in a test pane" "real_agents" "" expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi pi" expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1 expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $DIR/restore.out" 1 typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; } expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'" expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat" expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run" expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0" expect "neither runs: the shell waits for Enter" \ "r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash bash" expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE" expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0 expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine expect "the candidate is marked restored" \ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $STATE2/restore.json" restored r resize-window -t '=viewer:' -x 100 -y 30 expect "restored windows follow the terminal's size again" "r display -p -t '=0:edit' '#{window_width}'" 100 r resize-window -t '=viewer:' -x 120 -y 40 expect "and back" "r display -p -t '=0:edit' '#{window_width}'" 120 renv "$DINGGER" restore >"$DIR/restore2.out" 2>&1 expect "restoring again changes nothing" "fingerprint" "$BEFORE" expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0 [ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; } # Again, now with no tmux running at all (after a reboot, before a terminal # opens): restoring starts the server. Then "Not now" stays dismissed. names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; } WORK_BEFORE=$(names work) r kill-server expect "a new offer: all four sessions, tmux is not running" \ "python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80 mbr --restore work expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE" expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash expect "no real agent runs in a test pane (no server)" "real_agents" "" expect "the offer shrinks to what is still missing" \ "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \ "['0', 'keep', 'viewer']" 80 mbr --dismiss-offer >/dev/null expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" "" start_viewer sleep 2 expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1 # --- 17. a daemon killed mid-write leaves consistent state ------------------- echo "17. kill during a write" expect "20 kills right after done: hook and display state agree" \ "\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1 # --- 18. claude: install its hook; restore resumes its session --------------- echo "18. claude" CC="$DIR/cc-config"; mkdir -p "$CC/real" cat >"$CC/real/settings.json" <<'JSON' { "env": { "TRACE_TO_LANGSMITH": "true" }, "model": "sonnet", "hooks": { "SessionStart": [ { "matcher": "^(startup|resume|clear|compact|fork)$", "hooks": [ { "type": "command", "command": "bash '/x/herdr-agent-state.sh' session", "timeout": 10 } ] } ] }, "theme": "auto" } JSON cp "$CC/real/settings.json" "$CC/original.json" ln -s "$CC/real/settings.json" "$CC/settings.json" ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; } ccinstall >"$DIR/ccinstall.out" 2>&1 expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1 expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \ "['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']" expect "everything else unchanged" \ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same ccinstall >"$DIR/ccinstall2.out" 2>&1 expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json" echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json" expect "invalid JSON is not touched" \ "CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1 unchanged" # The exact command Claude would run, from the installed settings. HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json") expect "the hook prints nothing and exits 0, even with no daemon or bad input" \ "printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0" # A fake claude named claude: records its arguments, runs the SessionStart # hook with Claude's input (`claude ` starts session , `claude # --resume ` resumes it), then waits. cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null cat >"$DIR/rbin/claude" <> $DIR/claude-args id=\${2:-\$1} printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \ "\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD" exec $DIR/rbin/real/claude 100000 EOF chmod +x "$DIR/rbin/claude" # "talked" has a transcript; "fresh" never got a message, so it has none. mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl" r new-session -d -s cc -n talked -c "$W/two" r new-window -d -t '=cc:' -n fresh -c "$W/three" r send-keys -t '=cc:talked' 'claude talked' Enter r send-keys -t '=cc:fresh' 'claude fresh' Enter r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh' TALKED=$(r display -p -t '=cc:talked' '#{pane_id}') 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 : >"$DIR/claude-args" renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1 expect "no real agent runs in a test pane (claude)" "real_agents" "" expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked" expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)" expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1 expect "no transcript: its command line is typed, not run" \ "typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh 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" "" # --- 20. raise the right terminal window --------------------------------------- # Two terminal windows on one session (Ghostty's `new-session -As 0` does # that): nested clients, focused by typing a terminal's focus reports into # their outer panes. A fake osascript plays Ghostty: it answers the focused # terminal from a file and logs what it is told to focus. `open` is a fake # too: tests never activate a real app. echo "20. terminal windows" cat >"$DIR/osascript" <>$DIR/ghostty-queries # Focus moves on while Ghostty answers. [ -f $DIR/ghostty-race ] && env -u TMUX tmux -u -L $R send-keys -t "\$(cat $DIR/ghostty-race):" -H 1b 5b 4f && sleep 0.3 cat $DIR/ghostty-focused ;; *"focus terminal id"*) echo "\$2" | grep -o 'id "[^"]*"' | cut -d'"' -f2 >>$DIR/ghostty-raised [ ! -f $DIR/ghostty-gone ] ;; esac FAKE printf '#!/bin/sh\necho "$2" >> %s\n' "$DIR/opened" >"$DIR/open" chmod +x "$DIR/osascript" "$DIR/open" FAKE_APP="$DIR/Fake/Ghostty.app" start_viewer r set-environment -g DINGGER_OSASCRIPT "$DIR/osascript" r set-environment -g DINGGER_OPEN "$DIR/open" r set-environment -g DINGGER_TERMINAL_APP "$FAKE_APP" r set-environment -g DINGGER_TMUX_SOCKET "$R" r set -g focus-events on # The snippet's own focus hook, pointed at the binary under test. grep "client-focus-in" "$ROOT/packaging/tmux/dingger.tmux" | sed "s|\$HOME/.local/bin/dingger|$DINGGER|" >"$DIR/focus-hook.tmux" r source-file "$DIR/focus-hook.tmux" expect "the snippet's focus hook, on the binary under test" "r show-hooks -g client-focus-in | grep -c \"$DINGGER terminal focused\"" 1 r new-session -d -s viewer2 -x 120 -y 40 r send-keys -t viewer2 "exec env -u TMUX tmux -u -L $R attach -t 0" Enter expect "two terminal windows on session 0" "r list-clients -F '#{session_name}' | sort | tr '\n' ' '" "0 0 " 50 TTY1=$(r display -p -t viewer: '#{pane_tty}'); TTY2=$(r display -p -t viewer2: '#{pane_tty}') r new-window -d -t '=0:' -n agent2 "$DIR/rbin/real/pi 100000" AG=$(r display -p -t '=0:agent2' '#{pane_id}') r new-window -d -t '=0:' -n other r new-session -d -s elsewhere focus_in() { echo "$2" >"$DIR/ghostty-focused"; r send-keys -t "$1:" -H 1b 5b 49; } focus_out() { r send-keys -t "$1:" -H 1b 5b 4f; } paired() { r show -gv "@dingger_terminal_$(basename "$1")" 2>/dev/null | cut -d' ' -f2; } client_session() { r list-clients -F '#{client_tty} #{session_name}' | awk -v t="$1" '$1==t {print $2}'; } focus_out viewer; focus_out viewer2 focus_in viewer AAAA-1111 expect "focusing a window pairs its client with Ghostty's focused terminal" "paired $TTY1" AAAA-1111 50 focus_out viewer; focus_in viewer2 BBBB-2222 expect "and the other window's" "paired $TTY2" BBBB-2222 50 : >"$DIR/ghostty-raised" renv "$DINGGER" focus --pane "$AG" expect "a jump raises the focused window's terminal" "tail -1 $DIR/ghostty-raised" BBBB-2222 50 expect "which shows the agent" "r display -p -c $TTY2 '#{window_name}'" agent2 focus_out viewer2; focus_in viewer AAAA-1111; sleep 0.5; focus_out viewer r select-window -t '=0:other' renv "$DINGGER" focus --pane "$AG" expect "nothing focused: the most recently focused window's" "tail -1 $DIR/ghostty-raised" AAAA-1111 50 r switch-client -c "$TTY1" -t '=elsewhere' renv "$DINGGER" focus --pane "$AG" expect "a window already on the agent's session wins over a more recent one" "tail -1 $DIR/ghostty-raised" BBBB-2222 50 expect "and the other window is not moved" "client_session $TTY1" elsewhere echo viewer >"$DIR/ghostty-race"; : >"$DIR/ghostty-queries" focus_in viewer CCCC-3333 expect "Ghostty asked" "wc -l <$DIR/ghostty-queries | tr -d ' '" 1 50 sleep 1 expect "focus moved on while Ghostty answered: not paired" "paired $TTY1" AAAA-1111 rm -f "$DIR/ghostty-race" focus_in viewer2 AAAA-1111 expect "a terminal holds one client: the newer pairing" "paired $TTY2" AAAA-1111 50 expect "replaces the older one" "paired $TTY1" "" focus_out viewer2 touch "$DIR/ghostty-gone"; : >"$DIR/opened" renv "$DINGGER" focus --pane "$AG" expect "a terminal that is gone is forgotten" "paired $TTY2" "" 50 expect "and the app is brought forward instead" "cat $DIR/opened" "$FAKE_APP" 50 rm -f "$DIR/ghostty-gone"; : >"$DIR/opened"; : >"$DIR/ghostty-raised" renv DINGGER_TERMINAL_APP="$FAKE_APP" DINGGER_OPEN="$DIR/open" "$DINGGER" focus --pane "$AG" expect "no pairing: the app" "cat $DIR/opened" "$FAKE_APP" 50 expect "and no terminal raised" "cat $DIR/ghostty-raised" "" # A window on a session of a group stays on it: a pane target alone lets # tmux pick a session of the group, the most recently used. r new-session -d -t grp -s grp-b r switch-client -c "$TTY1" -t '=grp-b' r switch-client -c "$TTY2" -t '=grp'; sleep 1.1; r switch-client -c "$TTY2" -t '=0' renv "$DINGGER" focus --client "$TTY1" --pane "$(r display -p -t '=grp:agent' '#{pane_id}')" expect "a window on a session group stays on its own session" "client_session $TTY1" grp-b expect "showing the agent" "r display -p -c $TTY1 '#{window_name}'" agent # 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 echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; }