agents: sink visited blocked sessions to the bottom
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/.config/sketchybar/plugins/agents.sh b/.config/sketchybar/plugins/agents.sh
index 085f1d0f..9d12dc7c 100755
--- a/.config/sketchybar/plugins/agents.sh
+++ b/.config/sketchybar/plugins/agents.sh
@@ -85,7 +85,8 @@ cmd_status() {
fi
# list already orders blocked, then idle, then working, so the first row
- # names the most urgent state and the label counts how many share it.
+ # names the most urgent state and the label counts how many share it. The
+ # ordering within a state doesn't matter here — only which state leads.
# BSD head and cut have no long forms for these, so short flags it is.
local state count
state=$(head -n 1 <<<"$rows" | cut -f 1)
@@ -105,7 +106,9 @@ cmd_click() {
}
# list orders blocked first, so the top row is the session most likely to be
-# waiting on a human.
+# waiting on a human — and puts the ones already jumped to last within that
+# group, so repeated clicks walk the blocked sessions instead of sticking on
+# whichever one has been blocked longest.
jump_to_top() {
local pane
pane=$(state_rows | head -n 1 | cut -f 6)
@@ -192,6 +195,11 @@ cmd_jump() {
# three-step form races, as ,agents' comment explains at more length.
tmux switch-client ${target[@]+"${target[@]}"} -t "$pane" 2>/dev/null || return 0
+ # Sinks this pane to the bottom of its state group in `list`, so the next
+ # left click reaches for a different session. Ignored unless the pane is
+ # blocked, which ,agent-state decides for itself.
+ [[ -x $STATE_SCRIPT ]] && "$STATE_SCRIPT" visited "$pane" 2>/dev/null
+
# Raises the most recently used Ghostty window, which is the right one when
# a single window holds the tmux client. With several, macOS decides. The
# bundle id rather than -a Ghostty, since a name lookup can find the wrong
diff --git a/ai/bin/,agent-state b/ai/bin/,agent-state
index ba25bbc9..4c3ec932 100755
--- a/ai/bin/,agent-state
+++ b/ai/bin/,agent-state
@@ -3,8 +3,10 @@
# Tracks the state of Claude Code sessions, keyed by the tmux pane each one
# runs in, so ,agents can rank them by who is waiting on a human.
#
-# Written by ai/claude/hooks/agent-state.sh (the only writer), read by
-# ai/bin/,agents and by the sketchybar agents plugin.
+# Written by ai/claude/hooks/agent-state.sh, read by ai/bin/,agents and by the
+# sketchybar agents plugin. Those two readers also write, through `visited`:
+# jumping to a blocked session records the visit so the picker can move on to
+# the next one instead of offering the same session again.
#
# `list` is not read-only: it prunes rows whose pane is gone. The sketchybar
# plugin runs it every time the hook reports a state change, so most pruning
@@ -53,6 +55,13 @@ CREATE TABLE IF NOT EXISTS panes (
cwd TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT ''
);
+-- Its own table rather than a column on panes, so an existing database picks it
+-- up from CREATE TABLE IF NOT EXISTS. ALTER TABLE ADD COLUMN would need a
+-- migration step, and this schema runs on every connection.
+CREATE TABLE IF NOT EXISTS visits (
+ pane TEXT PRIMARY KEY,
+ visited_at INTEGER NOT NULL
+);
"
# Renders a value as a SQL string literal. SQLite escapes a single quote by
@@ -108,6 +117,33 @@ cmd_clear() {
$PRAGMAS
$SCHEMA
DELETE FROM panes WHERE pane = $(sql_quote "$TMUX_PANE");
+DELETE FROM visits WHERE pane = $(sql_quote "$TMUX_PANE");
+SQL
+}
+
+# Records that a human jumped to this pane while it was blocked, so `list` can
+# rank it below the blocked sessions they have not seen yet. Called by the two
+# places that switch a tmux client: ,agents and the sketchybar agents item.
+#
+# Whether the pane is blocked is decided here rather than trusted from the
+# caller. Switching a client fires no Claude hook, so the stored state is still
+# current at the moment of the jump, and a caller holding a stale row cannot
+# demote a session that has since gone back to working.
+cmd_visited() {
+ local pane=${1:-}
+
+ [[ $pane =~ ^%[0-9]+$ ]] || return 0
+ [[ -f $STATE_DB ]] || return 0
+
+ # The WHERE clause is what lets this parse: in an INSERT ... SELECT, SQLite
+ # cannot otherwise tell ON CONFLICT from the ON of a join.
+ db <<SQL >/dev/null
+$PRAGMAS
+$SCHEMA
+INSERT INTO visits (pane, visited_at)
+SELECT pane, $(date +%s) FROM panes
+WHERE pane = $(sql_quote "$pane") AND state = 'blocked'
+ON CONFLICT(pane) DO UPDATE SET visited_at = excluded.visited_at;
SQL
}
@@ -117,6 +153,11 @@ SQL
# Ordered by state (blocked, then idle, then working) and, within a state,
# oldest first — the session sitting in it longest is the one most likely to
# have been forgotten.
+#
+# Except for sessions already visited while blocked, which sink below the rest
+# of their state, least recently visited first. Without that, jumping to a
+# blocked session and leaving it blocked would land on the same row the next
+# time the picker opens, since visiting a pane does not change its age.
cmd_list() {
[[ -f $STATE_DB ]] || return 0
@@ -143,12 +184,23 @@ $SCHEMA
CREATE TEMP TABLE live (pane TEXT PRIMARY KEY, label TEXT NOT NULL);
INSERT OR IGNORE INTO live (pane, label) VALUES ${values%,};
DELETE FROM panes WHERE pane NOT IN (SELECT pane FROM live);
+DELETE FROM visits WHERE pane NOT IN (SELECT pane FROM live);
.mode list
.separator "\t"
+-- A visit counts only until the session next reports, so visited_at older than
+-- updated_at means the pane has moved on and belongs back in the age ordering.
+-- That also expires the demotion when a session blocks again: recording a state
+-- bumps updated_at past the visit, and a fresh block is worth surfacing.
+--
+-- Sorting the demoted rows on the visit rather than the age is what makes them
+-- rotate. max() picks whichever timestamp ranks the row, so both groups sort
+-- with one expression: the visit for a demoted row, the age for every other.
SELECT p.state, l.label, p.cwd, p.updated_at, p.message, p.pane
FROM panes p JOIN live l ON l.pane = p.pane
+LEFT JOIN visits v ON v.pane = p.pane
ORDER BY CASE p.state WHEN 'blocked' THEN 0 WHEN 'idle' THEN 1 ELSE 2 END,
- p.updated_at ASC;
+ CASE WHEN COALESCE(v.visited_at, 0) >= p.updated_at THEN 1 ELSE 0 END,
+ max(COALESCE(v.visited_at, 0), p.updated_at) ASC;
SQL
}
@@ -179,11 +231,12 @@ main() {
}
case $command in
- record) cmd_record "$@" ;;
- clear) cmd_clear ;;
- list) cmd_list ;;
+ record) cmd_record "$@" ;;
+ clear) cmd_clear ;;
+ list) cmd_list ;;
+ visited) cmd_visited "$@" ;;
*)
- echo "usage: ,agent-state <record|clear|list>" >&2
+ echo "usage: ,agent-state <record|clear|list|visited PANE>" >&2
return 2
;;
esac
diff --git a/ai/bin/,agents b/ai/bin/,agents
index 5815a48e..0d23aea1 100755
--- a/ai/bin/,agents
+++ b/ai/bin/,agents
@@ -1,9 +1,10 @@
#!/usr/bin/env bash
# Picker for running Claude Code sessions, ranked so the ones blocked on a
-# human come first. Bound to prefix+A in tmux.conf, where it runs in the same
-# floating pane as ,z and ,tmux-grab. State comes from ,agent-state, which the
-# Claude hook writes to.
+# human come first — barring any already jumped to and left blocked, which
+# ,agent-state sinks to the bottom of that group. Bound to prefix+A in
+# tmux.conf, where it runs in the same floating pane as ,z and ,tmux-grab.
+# State comes from ,agent-state, which the Claude hook writes to.
set -euo pipefail
if [[ "${TRACE-0}" == "1" ]]; then
@@ -77,7 +78,12 @@ main() {
# last-active window instead.
if ! tmux switch-client -t "$pane" 2>/dev/null; then
tmux display-message "agents: pane $pane is gone"
+ return 0
fi
+
+ # Recorded only once the jump lands, so a dead pane doesn't get demoted on
+ # the way out. ,agent-state ignores a pane that isn't blocked.
+ "$state_script" visited "$pane" || true
}
main "$@"