#!/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" # What pi agents cost: the daemons read session files here every 0.3 s. export DINGGER_COST_SCAN_SECS=0.3 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 "π - - ", or "π - " D=$(basename "$DIR") t select-pane -t "$A1" -T "π - Deploy the widget - $D" t select-pane -t "$A2" -T "π - $D" t select-pane -t "$B1" -T "π - Review PR 42 - $D" t new-session -d -s viewer -x 120 -y 40 "env -u TMUX tmux -u -L $SOCK attach -t b" for _ in $(seq 50); do CLIENT=$(t list-clients -F '#{client_name}' | head -1); [ -n "$CLIENT" ] && break; sleep 0.1; done [ -z "$CLIENT" ] && { echo "no client attached"; exit 1; } echo "panes A1=$A1 A2=$A2 B1=$B1, client $CLIENT on $(client_window)" # tmux focus hooks, as in packaging/tmux/dingger.tmux, pointed at the test daemon POKE="run-shell -b \"DINGGER_AGENT_SOCKET=$HOOK $DINGGER poke\"" [ -z "${E2E_NO_FOCUS_HOOKS:-}" ] && for h in client-session-changed session-window-changed window-pane-changed; do t set-hook -g "$h[77]" "$POKE"; done start_daemon # --- 1. first observation is idle, not done (herdr: initial idle stays seen) -- echo "1. first observation" for p in "$A1" "$A2" "$B1" "$C1"; do hook "$p" idle; done expect "A1 idle on first sight" "opt $A1 @agent_state" idle expect "A2 idle on first sight" "opt $A2 @agent_state" idle expect "B1 idle on first sight" "opt $B1 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" expect "A1 name from pi's title" "opt $A1 @agent_name" "Deploy the widget" expect "A2 unnamed pi session has no name" "opt $A2 @agent_name" "" expect "B1 name from pi's title" "opt $B1 @agent_name" "Review PR 42" # --- 2. working, then completion away from the window -> done ---------------- echo "2. completion while away" hook "$A1" working expect "A1 working within 0.5s (event-driven)" "opt $A1 @agent_state" working 5 expect "working count 1" "gopt @dingger_working" 1 hook "$A1" idle expect "A1 done within 0.5s despite a 3s notifier" "opt $A1 @agent_state" done 5 expect "done count 1" "gopt @dingger_done" 1 expect "queue = A1" "gopt @dingger_queue" "$A1" expect "'pi finished' notification for a:wa1" "grep -c 'pi finished|a:wa1 (pi)' $NOTES 2>/dev/null" 1 60 # --- 3. completion in the window the user is looking at -> idle -------------- echo "3. completion while looking" hook "$B1" working expect "B1 working" "opt $B1 @agent_state" working hook "$B1" idle expect "B1 idle (user is looking at wb1)" "opt $B1 @agent_state" idle expect "queue still = A1" "gopt @dingger_queue" "$A1" sleep 3.5 expect "no notification for a completion the user watched" "grep -c 'b:wb1' $NOTES" 0 # --- 4. FIFO and blocked ------------------------------------------------------- echo "4. fifo and blocked" hook "$A2" working hook "$A2" blocked expect "A2 blocked within 0.5s" "opt $A2 @agent_state" blocked 5 expect "blocked count 1" "gopt @dingger_blocked" 1 expect "'pi needs attention' notification for a:wa2" "grep -c 'pi needs attention|a:wa2 (pi)' $NOTES 2>/dev/null" 1 60 expect "queue = A2,A1 (blocked before done)" "gopt @dingger_queue" "$A2,$A1" hook "$C1" working; hook "$C1" idle expect "C1 done" "opt $C1 @agent_state" done expect "queue = A2,A1,C1 (oldest done first)" "gopt @dingger_queue" "$A2,$A1,$C1" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$C1" expect "client shows wc1" "client_window" wc1 expect "C1 idle after the jump" "opt $C1 @agent_state" idle # --- 5. attending by navigating --------------------------------------------- echo "5. navigate to a done agent" t switch-client -c "$CLIENT" -t "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle within 1s of looking (tmux focus hook)" "opt $A1 @agent_state" idle 10 expect "queue = A2" "gopt @dingger_queue" "$A2" expect "done count 0" "gopt @dingger_done" 0 # --- 6. status-line click: focus with no args jumps to the queue head -------- echo "6. dingger focus (status-line click)" DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus expect "client jumped to wa2" "client_window" wa2 expect "A2 still blocked (needs input even when seen)" "opt $A2 @agent_state" blocked expect "ack consumed" "opt $A2 @dingger_ack" "" hook "$A2" working hook "$A2" idle expect "A2 idle (completed while looked at)" "opt $A2 @agent_state" idle expect "queue empty" "gopt @dingger_queue" "" # --- 7. menu row click: focus --pane ----------------------------------------- echo "7. dingger focus --pane (menu row click)" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working hook "$A1" idle expect "A1 done again" "opt $A1 @agent_state" done DINGGER_TMUX_SOCKET="$SOCK" "$DINGGER" focus --client "$CLIENT" --pane "$A1" expect "client shows wa1" "client_window" wa1 expect "A1 idle after the jump" "opt $A1 @agent_state" idle # --- 8. a whole turn between two polls is still a completion ---------------- echo "8. sub-second turn" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A1" working; hook "$A1" idle # both within one poll interval expect "A1 done after a sub-second turn" "opt $A1 @agent_state" done t switch-client -c "$CLIENT" -t "$A1" expect "A1 idle once looked at" "opt $A1 @agent_state" idle # --- 9. restart keeps state ----------------------------------------------------- echo "9. daemon restart" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" working hook "$A2" idle expect "A2 done before restart" "opt $A2 @agent_state" done kill "$DAEMON_PID"; wait "$DAEMON_PID" 2>/dev/null start_daemon sleep 1.5 expect "A2 still done after restart" "opt $A2 @agent_state" done expect "A1 still idle after restart" "opt $A1 @agent_state" idle expect "B1 still idle after restart" "opt $B1 @agent_state" idle expect "queue = A2 after restart" "gopt @dingger_queue" "$A2" hook "$A2" working expect "hook state still authoritative after restart" "opt $A2 @agent_state" working # --- 10. the tmux status-line segment and its click binding ----------------- echo "10. status line" t switch-client -c "$CLIENT" -t "$B1" expect "client back on wb1" "client_window" wb1 hook "$A2" blocked hook "$A1" working; hook "$A1" idle expect "A2 blocked" "opt $A2 @agent_state" blocked expect "A1 done" "opt $A1 @agent_state" done t source-file "$ROOT/packaging/tmux/dingger.tmux" # 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. The input box holds // $EDITOR_TEXT; the session file is $SESSION_FILE. 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"); let editor = process.env.EDITOR_TEXT ?? ""; 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: () => process.env.SESSION_FILE }, ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`), getEditorText: () => editor, setEditorText: (text) => { editor = text; log(`editor ${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 expect "snapshot: the other agent's session" "in_snapshot gone.jsonl" True 80 # An agent that exits just before tmux dies (a shutdown stops programs first). kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')" expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" "" sleep 1.5 expect "snapshot: the exited agent is kept for a while" "in_snapshot gone.jsonl" True r kill-server expect "the dead server's layout becomes the restore candidate" \ "python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80 expect "the last snapshot is kept while tmux is down" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\"" 1 start_viewer r new-session -d -s keep -n mine "sleep 100000" expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}' | tr '\n' ' '" "1 " expect "the candidate survives the new server's snapshots" \ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80 expect "dry run plans: replace 0, keep keep and viewer, create work" \ "renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;" mbr() { renv DINGGER_BIN="$DINGGER" DINGGER_RESTORE_DIRECT=1 \ DINGGER_NOTIFIER="echo \"\$0|\$1\" >> $DIR/notes2" "$MENUBAR" "$@"; } expect "the daemon offers to restore 0 and work" \ "python3 -c 'import json,sys; o=json.load(open(sys.argv[1])); print([s[\"name\"] for s in o[\"sessions\"]], o[\"agents\"], o[\"interrupted\"])' $STATE2/offer.json 2>/dev/null" \ "['0', 'work'] 2 1" 80 expect "and tells the status line" "r show -gv @dingger_restore" 2 expect "the panel offers it first" "mbr --print-panel | head -1 | cut -f1,3" "↺ Restore 2 sessions · 2 agents [Not now]" expect "with what was interrupted" "mbr --print-panel | head -1 | cut -f2 | grep -o '1 was mid-turn'" "1 was mid-turn" mbr --restore cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0 expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl --session $W/sessions/gone.jsonl" expect "no real agent runs in a test pane" "real_agents" "" expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi pi" expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1 expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $DIR/restore.out" 1 typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; } expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'" expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat" expect "a notification summarizes" "cat $DIR/notes2" "Restored 2 tmux sessions|resumed 2 of 2 agents · 1 was mid-turn · 2 commands typed, not run" expect "the offer is gone" "ls $STATE2/offer.json 2>/dev/null; r show -gv @dingger_restore 2>/dev/null; mbr --print-panel | grep -c '↺'" "0" expect "neither runs: the shell waits for Enter" \ "r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash bash" expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE" expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0 expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine expect "the candidate is marked restored" \ "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $STATE2/restore.json" restored r resize-window -t '=viewer:' -x 100 -y 30 expect "restored windows follow the terminal's size again" "r display -p -t '=0:edit' '#{window_width}'" 100 r resize-window -t '=viewer:' -x 120 -y 40 expect "and back" "r display -p -t '=0:edit' '#{window_width}'" 120 renv "$DINGGER" restore >"$DIR/restore2.out" 2>&1 expect "restoring again changes nothing" "fingerprint" "$BEFORE" expect "restoring again plans nothing" "grep -cvE '^keep ' $DIR/restore2.out" 0 [ "$FAILS" -gt 0 ] && { echo "--- restore output:"; cat "$DIR/restore.out"; echo "--- before:"; echo "$BEFORE"; echo "--- after:"; fingerprint; } # Again, now with no tmux running at all (after a reboot, before a terminal # opens): restoring starts the server. Then "Not now" stays dismissed. names() { r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{pane_index}|#{pane_current_path}' 2>/dev/null | grep "^$1|"; } WORK_BEFORE=$(names work) r kill-server expect "a new offer: all four sessions, tmux is not running" \ "python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80 mbr --restore work expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE" expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash expect "no real agent runs in a test pane (no server)" "real_agents" "" expect "the offer shrinks to what is still missing" \ "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \ "['0', 'keep', 'viewer']" 80 mbr --dismiss-offer >/dev/null expect "Not now: the offer is gone" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 expect "and the status line is told" "r show -gv @dingger_restore 2>/dev/null" "" start_viewer sleep 2 expect "it stays dismissed when a terminal starts tmux" "ls $STATE2/offer.json 2>/dev/null | wc -l | tr -d ' '" 0 expect "the layout can still be restored by hand" "renv \"$DINGGER\" restore --dry-run | grep -c '^create keep'" 1 # --- 17. a daemon killed mid-write leaves consistent state ------------------- echo "17. kill during a write" expect "20 kills right after done: hook and display state agree" \ "\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1 # --- 18. claude: install its hook; restore resumes its session --------------- echo "18. claude" CC="$DIR/cc-config"; mkdir -p "$CC/real" cat >"$CC/real/settings.json" <<'JSON' { "env": { "TRACE_TO_LANGSMITH": "true" }, "model": "sonnet", "hooks": { "SessionStart": [ { "matcher": "^(startup|resume|clear|compact|fork)$", "hooks": [ { "type": "command", "command": "bash '/x/herdr-agent-state.sh' session", "timeout": 10 } ] } ] }, "theme": "auto" } JSON cp "$CC/real/settings.json" "$CC/original.json" ln -s "$CC/real/settings.json" "$CC/settings.json" ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; } ccinstall >"$DIR/ccinstall.out" 2>&1 expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1 expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \ "['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']" expect "everything else unchanged" \ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same ccinstall >"$DIR/ccinstall2.out" 2>&1 expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json" echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json" expect "invalid JSON is not touched" \ "CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1 unchanged" # The exact command Claude would run, from the installed settings. HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json") expect "the hook prints nothing and exits 0, even with no daemon or bad input" \ "printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0" # A fake claude named claude: records its arguments, runs the SessionStart # hook with Claude's input (`claude ` starts session , `claude # --resume ` resumes it), then waits. cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null cat >"$DIR/rbin/claude" <> $DIR/claude-args id=\${2:-\$1} printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \ "\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD" exec $DIR/rbin/real/claude 100000 EOF chmod +x "$DIR/rbin/claude" # "talked" has a transcript; "fresh" never got a message, so it has none. mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl" r new-session -d -s cc -n talked -c "$W/two" r new-window -d -t '=cc:' -n fresh -c "$W/three" r send-keys -t '=cc:talked' 'claude talked' Enter r send-keys -t '=cc:fresh' 'claude fresh' Enter r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh' TALKED=$(r display -p -t '=cc:talked' '#{pane_id}') expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude claude" 80 expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80 expect "snapshot: the command line of the claude without a transcript" "in_snapshot '\"command\": \"claude fresh\"'" True 80 r kill-server expect "a new restore candidate" \ "python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80 : >"$DIR/claude-args" renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1 expect "no real agent runs in a test pane (claude)" "real_agents" "" expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked" expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)" expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1 expect "no transcript: its command line is typed, not run" \ "typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh bash" expect "the resumed claude reports its session again" \ "r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80 [ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; } # --- 19. a session group: its panes once, its windows saved once ------------- echo "19. session group" start_viewer r new-session -d -s grp -n agent -c "$W/one" r new-window -d -t '=grp:' -n other -c "$W/two" r new-session -d -t grp -s grp-2 r send-keys -t '=grp:agent' 'pi' Enter GA=$(r display -p -t '=grp:agent' '#{pane_id}') expect "the group's agent is detected" "r show -pv -t $GA @agent 2>/dev/null" pi 80 expect "dingger status lists it once" "renv \"$DINGGER\" status | grep -c ' grp'" 1 expect "the menu lists it once" "mbr --print | grep -c 'grp'" 1 # Watched only through grp-2: grp shows another window, nobody attaches it. r select-window -t '=grp:other' r select-window -t '=grp-2:agent' r switch-client -c "$(r list-clients -F '#{client_name}' | head -1)" -t '=grp-2' touch "$W/sessions/grp.jsonl" # Wait for working: the agent was idle before, so idle alone would not show the turn. hook2 "$GA" working "$W/sessions/grp.jsonl" expect "the group's agent working" "r show -pv -t $GA @agent_state" working 80 hook2 "$GA" idle "$W/sessions/grp.jsonl" expect "a turn finished while watched through grp-2 is idle, not done" "r show -pv -t $GA @agent_state" idle 80 expect "snapshot: the group is saved once" \ "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"] if s[\"name\"].startswith(\"grp\")))' \"\$(latest_snapshot)\"" \ "['grp']" 80 expect "snapshot: with the agent's session" "in_snapshot '\"session\": \"$W/sessions/grp.jsonl\"'" True 80 r kill-server expect "the candidate: the group once" \ "python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"] if s[\"name\"].startswith(\"grp\")))' $STATE2/restore.json 2>/dev/null" "['grp']" 80 expect "dry run plans the group once" "renv \"$DINGGER\" restore --dry-run 2>/dev/null | grep -E '^[a-z]+ +grp' | cut -c1-14" "create grp: " : >"$DIR/pi-args" renv "$DINGGER" restore --session grp >"$DIR/restore-grp.out" 2>&1 expect "restore: the agent is resumed once" "grep -c -- '--session $W/sessions/grp.jsonl' $DIR/pi-args" 1 expect "with both windows" "r list-windows -t '=grp' -F '#{window_name}' | tr '\n' ' '" "agent other " expect "no real agent runs in a test pane (group)" "real_agents" "" # --- 20. raise the right terminal window --------------------------------------- # Two terminal windows on one session (Ghostty's `new-session -As 0` does # that): nested clients, focused by typing a terminal's focus reports into # their outer panes. A fake osascript plays Ghostty: it answers the focused # terminal from a file and logs what it is told to focus. `open` is a fake # too: tests never activate a real app. echo "20. terminal windows" cat >"$DIR/osascript" <>$DIR/ghostty-queries # Focus moves on while Ghostty answers. [ -f $DIR/ghostty-race ] && env -u TMUX tmux -u -L $R send-keys -t "\$(cat $DIR/ghostty-race):" -H 1b 5b 4f && sleep 0.3 cat $DIR/ghostty-focused ;; *"focus terminal id"*) echo "\$2" | grep -o 'id "[^"]*"' | cut -d'"' -f2 >>$DIR/ghostty-raised [ ! -f $DIR/ghostty-gone ] ;; esac FAKE printf '#!/bin/sh\necho "$2" >> %s\n' "$DIR/opened" >"$DIR/open" chmod +x "$DIR/osascript" "$DIR/open" FAKE_APP="$DIR/Fake/Ghostty.app" start_viewer r set-environment -g DINGGER_OSASCRIPT "$DIR/osascript" r set-environment -g DINGGER_OPEN "$DIR/open" r set-environment -g DINGGER_TERMINAL_APP "$FAKE_APP" r set-environment -g DINGGER_TMUX_SOCKET "$R" r set -g focus-events on # The snippet's own focus hook, pointed at the binary under test. grep "client-focus-in" "$ROOT/packaging/tmux/dingger.tmux" | sed "s|\$HOME/.local/bin/dingger|$DINGGER|" >"$DIR/focus-hook.tmux" r source-file "$DIR/focus-hook.tmux" expect "the snippet's focus hook, on the binary under test" "r show-hooks -g client-focus-in | grep -c \"$DINGGER terminal focused\"" 1 r new-session -d -s viewer2 -x 120 -y 40 r send-keys -t viewer2 "exec env -u TMUX tmux -u -L $R attach -t 0" Enter expect "two terminal windows on session 0" "r list-clients -F '#{session_name}' | sort | tr '\n' ' '" "0 0 " 50 TTY1=$(r display -p -t viewer: '#{pane_tty}'); TTY2=$(r display -p -t viewer2: '#{pane_tty}') r new-window -d -t '=0:' -n agent2 "$DIR/rbin/real/pi 100000" AG=$(r display -p -t '=0:agent2' '#{pane_id}') r new-window -d -t '=0:' -n other r new-session -d -s elsewhere focus_in() { echo "$2" >"$DIR/ghostty-focused"; r send-keys -t "$1:" -H 1b 5b 49; } focus_out() { r send-keys -t "$1:" -H 1b 5b 4f; } paired() { r show -gv "@dingger_terminal_$(basename "$1")" 2>/dev/null | cut -d' ' -f2; } client_session() { r list-clients -F '#{client_tty} #{session_name}' | awk -v t="$1" '$1==t {print $2}'; } focus_out viewer; focus_out viewer2 focus_in viewer AAAA-1111 expect "focusing a window pairs its client with Ghostty's focused terminal" "paired $TTY1" AAAA-1111 50 focus_out viewer; focus_in viewer2 BBBB-2222 expect "and the other window's" "paired $TTY2" BBBB-2222 50 : >"$DIR/ghostty-raised" renv "$DINGGER" focus --pane "$AG" expect "a jump raises the focused window's terminal" "tail -1 $DIR/ghostty-raised" BBBB-2222 50 expect "which shows the agent" "r display -p -c $TTY2 '#{window_name}'" agent2 # Focus 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 # --- 24. forgotten drafts ----------------------------------------------------- # pi's extension reports the draft in its input box; dingger marks it where # the user is not looking, and keeps it in a file per session that comes back # with the session. (Typing into a real pi: tests/pi-live.sh.) echo "24. forgotten drafts" draft_report() { # pane draft python3 - "$HOOK" "$1" "$2" <<'PY' import json, socket, sys params = {"pane_id": sys.argv[2], "agent": "pi", "draft": sys.argv[3]} 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 dr -n d1 -c "$DIR" "$DIR/bin/pi" t new-window -d -t dr: -n d2 -c "$DIR" "$DIR/bin/pi" t new-window -d -t dr: -n away -c "$DIR" "sleep 100000" D1=$(t display -p -t dr:d1 '#{pane_id}'); D2=$(t display -p -t dr:d2 '#{pane_id}') look "$(t display -p -t dr:away '#{pane_id}')" for p in "$D1" "$D2"; do report "$p" working ""; report "$p" idle ""; expect "dr: $p done" "opt $p @agent_state" done; done look "$D1"; sleep 1; look "$D2"; sleep 1; look "$(t display -p -t dr:away '#{pane_id}')" expect "dr: both idle" "opt $D1 @agent_state; opt $D2 @agent_state" "idle idle" draft_report "$D2" "$(printf 'fix the\n sidecar;')" expect "a draft, as one line, its trailing ; kept" "opt $D2 @dingger_draft" "fix the sidecar;" expect "pane options after it still written" "opt $D2 @agent_state" idle drow() { mb --print | grep -F "dr · $1 ·" | cut -f2; } expect "menu: marked ✎" "drow d2" "✎ pi" expect "first among idle agents" "mb --print | grep -F 'dr · d' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "d2 d1 " expect "search finds it by its draft" "mb --print-search sidecar | head -1 | cut -f3" "dr · d2 · pi" expect "dingger status marks it" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 1 look "$D2" expect "where you look, no mark: you may be typing it" "drow d2" "pi" expect "nor in dingger status" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 0 look "$D1" draft_report "$D2" "" expect "a cleared draft: gone" "opt $D2 @dingger_draft" "" expect "menu: no mark" "drow d2" "pi" draft_report "$D2" ";" expect "a draft of just ;" "opt $D2 @dingger_draft" ";" report "$D2" working "" expect "a report without a draft keeps it" "opt $D2 @dingger_draft" ";" if command -v node >/dev/null; then DRAFTS="$(dirname "$HOOK")/drafts" EDITOR_TEXT="half a thought" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start expect "the extension reports its draft" "opt $D1 @dingger_draft" "half a thought" expect "and keeps it for the session" "cat $DRAFTS/s1.jsonl.draft" "half a thought" : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start expect "the session again: its draft back in the input box" "grep '^editor' $EXTLOG" "editor half a thought" expect "and reported" "opt $D1 @dingger_draft" "half a thought" : >"$EXTLOG"; EDITOR_TEXT="typed meanwhile" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start expect "an input box with text keeps it" "grep -c '^editor' $EXTLOG" 0 expect "and that is the draft now" "cat $DRAFTS/s1.jsonl.draft" "typed meanwhile" : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s2.jsonl" iext "$D1" session_start expect "another session: none of it" "grep -c '^editor' $EXTLOG" 0 expect "dr: no draft reported" "opt $D1 @dingger_draft" "" fi t kill-session -t dr # --- 25. what agents cost ----------------------------------------------------- # The daemon reads pi's session files: each reply once, for the session it # first appeared in (a fork copies its parent's), closed agents included. echo "25. what agents cost" SESS="$PI_CODING_AGENT_DIR/sessions/--work--" mkdir -p "$SESS" reply() { # id cost [days ago] python3 -c 'import json,sys,time; print(json.dumps({"type":"message","id":sys.argv[1],"message":{"role":"assistant","timestamp":int((time.time()-86400*float(sys.argv[3]))*1000),"usage":{"cost":{"total":float(sys.argv[2])}}}}))' "$1" "$2" "${3:-0}" } header() { echo "{\"type\":\"session\",\"timestamp\":\"$1\"}"; } { header 2026-01-01T00:00:00Z; reply p0 100 40; reply p1 1.25; reply p2 0.25; } >"$SESS/parent.jsonl" # The fork: its parent's entries, then its own. { header 2026-01-02T00:00:00Z; tail -n +2 "$SESS/parent.jsonl"; reply f1 0.5; } >"$SESS/fork.jsonl" expect "today, each reply once (not 40 days ago)" "gopt @dingger_cost_today" 2.00 50 expect "this week" "gopt @dingger_cost_week" 2.00 expect "this month" "gopt @dingger_cost_month" 2.00 session_report() { # pane session-file python3 - "$HOOK" "$1" "$2" <<'PY' import json, socket, sys params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "agent_session_path": sys.argv[3]} 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 cost -n c1 -c "$DIR" "$DIR/bin/pi" t new-window -d -t cost: -n c2 -c "$DIR" "$DIR/bin/pi" C1=$(t display -p -t cost:c1 '#{pane_id}'); C2=$(t display -p -t cost:c2 '#{pane_id}') session_report "$C1" "$SESS/parent.jsonl"; session_report "$C2" "$SESS/fork.jsonl" expect "an agent: what its session spent today" "opt $C1 @dingger_cost" 1.50 expect "a fork: only its own replies" "opt $C2 @dingger_cost" 0.50 expect "the menu's top line" "mb --print | grep '^\\$ '" '$ $2.00 today · $2.00 this week · $2.00 this month' expect "an agent's line" "mb --print | grep -F 'cost · c1' | cut -f3" "cost · c1 · pi · \$1.50" expect "dingger status: per agent" "$DINGGER status | grep -F 'cost · c2' | grep -c ' \$0.50 '" 1 expect "and in total" "$DINGGER status | tail -1" 'spent: $2.00 today · $2.00 this week · $2.00 this month (UTC)' reply f2 0.75 >>"$SESS/fork.jsonl" expect "a new reply: counted" "opt $C2 @dingger_cost" 1.25 50 expect "in the total too" "gopt @dingger_cost_today" 2.75 t kill-session -t cost expect "closed agents still count" "gopt @dingger_cost_today" 2.75 10 # --- 26. how full each agent's context is -------------------------------------- echo "26. context use" context_report() { # pane context python3 - "$HOOK" "$1" "$2" <<'PY' import json, socket, sys params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "context": sys.argv[3]} 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 } # A process named pi (found by name, so it can also exit), started in a # shell that stays. t new-session -d -s ctx -n x1 -c "$DIR" "/bin/sh" X1=$(t display -p -t ctx:x1 '#{pane_id}') t send-keys -t "$X1" "$DIR/rbin/real/pi 100000" Enter # The daemon polls slowly here; a poke has it look now. expect "a pi agent" "$DINGGER poke; opt $X1 @agent" pi 80 context_report "$X1" 78.4 expect "kept in whole percent" "opt $X1 @dingger_context" 78 50 expect "the menu: its percentage" "mb --print | grep -F 'ctx · x1' | cut -f3" "ctx · x1 · pi · 78%" expect "the panel: in its own column" "mb --print-panel --full | grep -F 'ctx · x1' | cut -f3,4" "ctx · x1 · pi 78%" expect "dingger status: CTX" "$DINGGER status | grep -F 'ctx · x1' | grep -c ' 78% '" 1 context_report "$X1" "" expect "unknown (compacted): none" "opt $X1 @dingger_context" "" 50 expect "nor in the menu" "mb --print | grep -F 'ctx · x1' | cut -f3" "ctx · x1 · pi" context_report "$X1" 12 expect "known again" "opt $X1 @dingger_context" 12 50 t send-keys -t "$X1" C-c expect "the agent exits" "$DINGGER poke; opt $X1 @agent" "" 80 expect "its context goes with it" "opt $X1 @dingger_context" "" 50 t kill-session -t ctx # 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; }