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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
|
#!/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 "π - <session name> - <cwd basename>", or "π - <dir>"
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 <id>` starts session <id>, `claude
# --resume <id>` 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" <<EOF
#!/bin/sh
echo "\$*" >> $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" <<FAKE
#!/bin/sh
case "\$2" in
*"focused terminal"*)
echo query >>$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; }
|