all repos — dingger @ b0cfd10b80443d3f67d263ff4db177bb53cc90ed

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

tests/e2e.sh (view raw)

 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
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
 1000
 1001
 1002
 1003
 1004
 1005
 1006
 1007
 1008
 1009
 1010
 1011
 1012
 1013
 1014
 1015
 1016
 1017
 1018
 1019
 1020
 1021
 1022
 1023
 1024
 1025
 1026
 1027
 1028
 1029
 1030
 1031
 1032
 1033
 1034
 1035
 1036
 1037
 1038
 1039
 1040
 1041
#!/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"
# The daemon updates an installed pi extension when it starts: not the user's.
export PI_CODING_AGENT_DIR="$DIR/pi-agent"

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
  if [ -n "${E2E_KEEP:-}" ]; then echo "kept $DIR"; else rm -rf "$DIR"; fi
}
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. Sounds are on, played by a fake
# player that logs them.
NOTES="$DIR/notifications"
SOUNDS="$DIR/sounds"
start_daemon() {
  DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" DINGGER_SOUND="echo \"\$0\" >> $SOUNDS" RUST_LOG=debug \
    "$DINGGER" watch --interval-secs 30 >>"$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"
# The counts are published right after the state: poll, like expect.
for _ in $(seq 40); do
  segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
  case "$segment" in *"×1 ✓1") break ;; esac
  sleep 0.1
done
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 | grep -v '^⟳' | 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 | grep -v '^⟳' | 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
// ("agent_start"), runs commands ("/importance high"), and asks for argument
// completions ("complete:importance h"). Custom session entries persist in
// $SESSION_ENTRIES, so a later run reads them back as the session; what the
// extension shows and emits goes to $EXT_LOG.
import fs from "node:fs";
const ext = await import(process.env.EXTENSION);
const on = {}, commands = {};
const store = process.env.SESSION_ENTRIES;
const entries = store && fs.existsSync(store) ? JSON.parse(fs.readFileSync(store, "utf8")) : [];
const log = (line) => process.env.EXT_LOG && fs.appendFileSync(process.env.EXT_LOG, line + "\n");
ext.default({
  on: (name, fn) => (on[name] = fn),
  registerCommand: (name, command) => (commands[name] = command),
  appendEntry: (customType, data) => {
    entries.push({ type: "custom", customType, data });
    if (store) fs.writeFileSync(store, JSON.stringify(entries));
  },
  events: { on: () => {}, emit: (name, data) => log(`event ${name} ${JSON.stringify(data)}`) },
});
const ctx = {
  mode: "tui",
  isIdle: () => true,
  sessionManager: { getEntries: () => entries, getSessionFile: () => undefined },
  ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`) },
};
for (const arg of process.argv.slice(2)) {
  if (arg.startsWith("/")) {
    const [name, ...rest] = arg.slice(1).split(" ");
    await commands[name]?.handler(rest.join(" "), ctx);
  } else if (arg.startsWith("complete:")) {
    const [name, ...rest] = arg.slice("complete:".length).split(" ");
    const items = await commands[name]?.getArgumentCompletions?.(rest.join(" "));
    log(`complete ${(items ?? []).map((item) => item.value).join(" ")}`);
  } else {
    await on[arg]?.({ type: arg, reason: "reload" }, ctx);
  }
  await new Promise((r) => setTimeout(r, 300));
}
JS
# The extension as installed: stamped with its version.
mkdir -p "$DIR/pi-harness"
PI_CODING_AGENT_DIR="$DIR/pi-harness" "$DINGGER" install pi >/dev/null
ext() { # pane events...  (run "inside" a pane: tmux sets TMUX_PANE there)
  local pane=$1; shift
  TMUX_PANE="$pane" EXTENSION="$DIR/pi-harness/extensions/dingger-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 held past learn's settling second.
focus_out viewer2; focus_in viewer AAAA-1111; sleep 1.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
# Across a Space switch Ghostty still names the terminal that had focus
# for a moment: the other window's.
focus_out viewer; focus_in viewer2 AAAA-1111
sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused"
sleep 1
expect "Ghostty lagging behind tmux's focus: the right terminal" "paired $TTY2" BBBB-2222 50
expect "and the other window keeps its own" "paired $TTY1" AAAA-1111
# Pairings crossed (as the lag used to leave them): Ghostty's lagging
# answer is then the client's own wrong terminal.
r set -g "@dingger_terminal_$(basename "$TTY1")" "1 BBBB-2222 $FAKE_APP"
r set -g "@dingger_terminal_$(basename "$TTY2")" "1 AAAA-1111 $FAKE_APP"
focus_out viewer2; focus_in viewer2 AAAA-1111
sleep 0.5; echo BBBB-2222 >"$DIR/ghostty-focused"
expect "crossed pairings heal: the focused client's" "paired $TTY2" BBBB-2222 50
expect "and the other's wrong one is dropped" "paired $TTY1" ""
focus_out viewer2; focus_in viewer AAAA-1111
expect "until its next focus" "paired $TTY1" AAAA-1111 50
r set -g @dingger_terminal_ttysgone "1 DDDD-4444 $FAKE_APP"
focus_out viewer; focus_in viewer2 BBBB-2222
expect "a client no longer attached loses its pairing" "r show -gv @dingger_terminal_ttysgone 2>/dev/null" "" 50
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
expect "which tmux does not report as an error (view mode over a pane)" "r list-panes -a -F '#{pane_mode}' | grep -c view-mode" 0
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
expect "nor that" "r list-panes -a -F '#{pane_mode}' | grep -c view-mode" 0
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" ""
# tmux answers for the current client when asked about one that is gone.
r set -g "@dingger_terminal_$(basename "$TTY1")" "$(date +%s)000 AAAA-1111 $FAKE_APP"
r set -g "@dingger_terminal_$(basename "$TTY2")" "$(date +%s)000 BBBB-2222 $FAKE_APP"
r run-shell "$DINGGER terminal raise --client /dev/gone"
sleep 0.5
expect "a client that is gone raises no other client's terminal" "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

# --- 21. importance --------------------------------------------------------
# Fresh agents on the first server (its daemon polls every 30 s: updates
# arrive by reports and pokes). Nobody looks at session imp.
echo "21. importance"
report() { # pane state importance (either may be empty)
  python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
import json, socket, sys
params = {"pane_id": sys.argv[2], "agent": "pi"}
if sys.argv[3]: params["state"] = sys.argv[3]
if sys.argv[4]: params["importance"] = sys.argv[4]
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
s.close()
PY
}
t new-session -d -s imp -n i1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
t new-window -d -t imp: -n i2 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t imp: -n i3 -c "$DIR" "$DIR/bin/pi"
t new-window -d -t imp: -n i4 -c "$DIR" "$DIR/bin/pi"
I1=$(t display -p -t imp:i1 '#{pane_id}'); I2=$(t display -p -t imp:i2 '#{pane_id}')
I3=$(t display -p -t imp:i3 '#{pane_id}'); I4=$(t display -p -t imp:i4 '#{pane_id}')
iqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$I1|$I2|$I3" | tr '\n' ' '; }
for p in "$I1" "$I2" "$I3"; do report "$p" working ""; expect "imp: $p working" "opt $p @agent_state" working; done
for p in "$I1" "$I2" "$I3"; do report "$p" idle ""; expect "imp: $p done" "opt $p @agent_state" done; sleep 0.05; done
expect "same importance: oldest done first" "iqueue" "$I1 $I2 $I3 "
report "$I3" "" high
expect "a high agent's pane option" "opt $I3 @dingger_importance" high
expect "importance before time: the high agent first" "iqueue" "$I3 $I1 $I2 "
expect "and its status unchanged (a report without state)" "opt $I3 @agent_state" done
report "$I1" "" low
expect "a low agent last" "iqueue" "$I3 $I2 $I1 "
report "$I3" "" normal
expect "normal leaves the option unset" "opt $I3 @dingger_importance" ""
expect "and the order by time again" "iqueue" "$I2 $I3 $I1 "
report "$I3" "" high
expect "imp: high again" "iqueue" "$I3 $I2 $I1 "
report "$I1" blocked ""
expect "imp: a low agent blocked" "opt $I1 @agent_state" blocked
expect "status before importance: the blocked low agent first" "iqueue" "$I1 $I3 $I2 "
report "$I1" working ""
expect "imp: working again" "opt $I1 @agent_state" working
# Let every alert so far land (the fake notifier takes 3 s).
sleep 3.5; : >"$SOUNDS"; : >"$NOTES"
report "$I1" working ""; report "$I1" idle ""
expect "a low agent finishing" "opt $I1 @agent_state" done
sleep 0.5
expect "makes no sound" "cat $SOUNDS" ""
expect "but still notifies" "grep -c '^pi finished|imp:i1' $NOTES" 1 60
report "$I2" working ""; report "$I2" idle ""
expect "a normal agent finishing sounds" "cat $SOUNDS" done
mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
expect "menu: a high agent is marked" "mb --print | grep -F 'imp · i3' | cut -f2" "! pi"
expect "menu: a low agent says so" "mb --print | grep -F 'imp · i1' | cut -f3" "imp · i1 · pi · low · done now"
expect "menu: high, normal, low among the done" \
  "mb --print | grep -F 'imp · ' | grep '^✓' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "i3 i2 i1 "
expect "dingger importance shows it" "$DINGGER importance --pane $I1" low
expect "a pi that reports itself is set in pi" \
  "$DINGGER importance high --pane $I1 2>&1" "dingger: this pi reports to dingger: use /importance in it (pi saves it with the session)"
expect "dingger importance needs an agent" "$DINGGER importance high --pane \$(t display -p -t viewer: '#{pane_id}') 2>&1 | cut -c1-24" "dingger: no agent in pan"
$DINGGER poke; expect "imp: the plain pi is identified" "opt $I4 @agent" pi
expect "dingger importance sets another agent's" "$DINGGER importance high --pane $I4" "importance: high"
expect "the daemon writes it" "opt $I4 @dingger_importance" high
t respawn-pane -k -t "$I4" "sleep 100000"; $DINGGER poke
expect "it goes with the agent" "opt $I4 @dingger_importance" ""
kill "$DAEMON_PID"; sleep 0.3; start_daemon; $DINGGER poke
sleep 1
expect "a daemon restart keeps it" "opt $I3 @dingger_importance" high
expect "and the order" "iqueue" "$I3 $I2 $I1 "
if command -v node >/dev/null; then
  ENTRIES="$DIR/entries.json"; EXTLOG="$DIR/ext.log"
  iext() { SESSION_ENTRIES="$ENTRIES" EXT_LOG="$EXTLOG" ext "$@"; }
  iext "$I2" session_start "/importance high"
  expect "/importance high reaches dingger" "opt $I2 @dingger_importance" high
  expect "saved in the session" "grep -c '\"customType\":\"dingger-importance\",\"data\":{\"level\":\"high\"}' $ENTRIES" 1
  expect "no footer status in pi" "grep -c '^status' $EXTLOG" 0
  expect "announced to other extensions" "grep -c '^event dingger:importance {\"level\":\"high\"}$' $EXTLOG" 1
  report "$I2" "" normal
  expect "imp: set back by hand" "opt $I2 @dingger_importance" ""
  # Unanswered, the extension sends a report again after 0.5 s, and gives
  # up on it after 2 s.
  working() { sed 's/\x1b\[[0-9;]*m//g' "$DIR/daemon.log" | grep -c "hook report pane=$I2 state=Working"; }
  before=$(working)
  start=$(python3 -c 'import time; print(time.time())')
  iext "$I2" agent_start
  took=$(python3 -c 'import time,sys; print(round(time.time() - float(sys.argv[1]), 2))' "$start")
  sleep 0.6
  expect "the daemon answers a report: the extension sends it once" "echo \$((\$(working) - before))" 1
  expect "and is done with it at once ($took s, with the harness's 0.3 s pause)" \
    "python3 -c 'import sys; print(float(sys.argv[1]) < 1.5)' $took" True 1
  iext "$I2" session_start
  expect "a new pi on the session reports it again" "opt $I2 @dingger_importance" high
  # pi starts a session before any command runs.
  : >"$EXTLOG"; iext "$I2" session_start "/importance urgent" "complete:importance h" "/importance normal"
  expect "an unknown level is refused" "grep -c '^notify importance is high, normal, or low$' $EXTLOG" 1
  expect "levels complete" "grep '^complete' $EXTLOG" "complete high"
  expect "normal reaches dingger" "opt $I2 @dingger_importance" ""
fi

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

# --- 23. agents on an old extension ------------------------------------------
# pi's extension reports its version and control socket; the daemon counts
# the agents on another version, which the menu offers to reload. (The
# reload itself, against a real pi: tests/pi-live.sh.)
echo "23. agents on an old extension"
ext_report() { # pane version control
  python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
import json, socket, sys
params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "extension": sys.argv[3], "control": sys.argv[4]}
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
s.close()
PY
}
mkdir -p "$DIR/pi-current"
PI_CODING_AGENT_DIR="$DIR/pi-current" $DINGGER install pi >/dev/null
CURRENT=$(grep -o 'extensionVersion = "[0-9a-f]*"' "$DIR/pi-current/extensions/dingger-agent-state.ts" | cut -d'"' -f2)
t new-session -d -s ext -n e1 -c "$DIR" "$DIR/bin/pi"
E1=$(t display -p -t ext:e1 '#{pane_id}')
# The agents reported so far sent no version: an extension from before
# versions, which only a /reload by hand updates.
expect "ext: the others are by hand" "[ \"\$(gopt @dingger_outdated_by_hand)\" -gt 0 ] && echo yes" yes
BY_HAND=$(gopt @dingger_outdated_by_hand)
ext_report "$E1" 0000000000000000 "$DIR/pi-0.sock"
expect "ext: the version is on the pane" "opt $E1 @dingger_extension" 0000000000000000
expect "and its control socket" "opt $E1 @dingger_control" "$DIR/pi-0.sock"
expect "another version: reloadable" "gopt @dingger_outdated" 1
expect "the menu offers to reload it" "mb --print | grep '^⟳'" \
  "$(printf '⟳ Reload 1 Agent on an Old Extension\teach when idle and off screen · %s more need /reload' "$BY_HAND")"
expect "reload-agents refuses without the current extension installed" "$DINGGER reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1
ext_report "$E1" "$CURRENT" "$DIR/pi-1.sock"
expect "the current version: not counted" "gopt @dingger_outdated" 0
expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" \
  "$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")"
t kill-session -t ext

# 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; }